Initial commit

This commit is contained in:
2023-02-23 18:13:04 -06:00
commit 43c3e1563f
298 changed files with 18530 additions and 0 deletions

27
.env.example Normal file
View File

@@ -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=

26
.fossa.yml Normal file
View File

@@ -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

12
.gitattributes vendored Normal file
View File

@@ -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

19
.github/stale.yml vendored Normal file
View File

@@ -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

36
.github/workflows/build-backend.yaml vendored Normal file
View File

@@ -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

36
.github/workflows/build-frontend.yaml vendored Normal file
View File

@@ -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

View File

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

View File

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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.docker/data/
ssl/
.env
.wwebjs_auth

1
.sonarcloud.properties Normal file
View File

@@ -0,0 +1 @@
sonar.exclusions=frontend/src/translate/languages/*,**/__tests__/**/*

21
LICENSE Normal file
View File

@@ -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.

2
README.md Normal file
View File

@@ -0,0 +1,2 @@
# whaticket
Whaticket

6
backend/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
*Dockerfile*
*docker-compose*
node_modules
dist
.wwebjs_auth

9
backend/.editorconfig Normal file
View File

@@ -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

3
backend/.eslintignore Normal file
View File

@@ -0,0 +1,3 @@
/*.js
node_modules
dist

49
backend/.eslintrc.json Normal file
View File

@@ -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": {}
}
}
}

18
backend/.gitignore vendored Normal file
View File

@@ -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/

8
backend/.sequelizerc Normal file
View File

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

40
backend/Dockerfile Normal file
View File

@@ -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

186
backend/jest.config.js Normal file
View File

@@ -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: ["<rootDir>/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: [
// "<rootDir>"
// ],
// 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,
};

80
backend/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,5 @@
module.exports = {
singleQuote: false,
trailingComma: "none",
arrowParens: "avoid"
};

0
backend/public/.gitkeep Normal file
View File

5
backend/src/@types/express.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare namespace Express {
export interface Request {
user: { id: string; profile: string };
}
}

View File

@@ -0,0 +1 @@
declare module "qrcode-terminal";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import database from "../../database";
const truncate = async (): Promise<void> => {
await database.truncate({ force: true, cascade: true });
};
const disconnect = async (): Promise<void> => {
return database.connectionManager.close();
};
export { truncate, disconnect };

43
backend/src/app.ts Normal file
View File

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

5
backend/src/bootstrap.ts Normal file
View File

@@ -0,0 +1,5 @@
import dotenv from "dotenv";
dotenv.config({
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env"
});

View File

@@ -0,0 +1,6 @@
export default {
secret: process.env.JWT_SECRET || "mysecret",
expiresIn: "15m",
refreshSecret: process.env.JWT_REFRESH_SECRET || "myanothersecret",
refreshExpiresIn: "7d"
};

View File

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

View File

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

View File

@@ -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<Response> => {
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();
};

View File

@@ -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<Response> => {
const { searchParam, pageNumber } = req.query as IndexQuery;
const { contacts, count, hasMore } = await ListContactsService({
searchParam,
pageNumber
});
return res.json({ contacts, count, hasMore });
};
export const getContact = async (req: Request, res: Response): Promise<Response> => {
const { name, number } = req.body as IndexGetContactQuery;
const contact = await GetContactService({
name,
number
});
return res.status(200).json(contact);
};
export const store = async (req: Request, res: Response): Promise<Response> => {
const newContact: ContactData = req.body;
newContact.number = newContact.number.replace("-", "").replace(" ", "");
const schema = Yup.object().shape({
name: Yup.string().required(),
number: Yup.string()
.required()
.matches(/^\d+$/, "Invalid number format. Only numbers is allowed.")
});
try {
await schema.validate(newContact);
} catch (err) {
throw new AppError(err.message);
}
await CheckIsValidContact(newContact.number);
const validNumber : any = await CheckContactNumber(newContact.number)
const profilePicUrl = await GetProfilePicUrl(validNumber);
let name = newContact.name
let number = validNumber
let email = newContact.email
let extraInfo = newContact.extraInfo
const contact = await CreateContactService({
name,
number,
email,
extraInfo,
profilePicUrl
});
const io = getIO();
io.emit("contact", {
action: "create",
contact
});
return res.status(200).json(contact);
};
export const show = async (req: Request, res: Response): Promise<Response> => {
const { contactId } = req.params;
const contact = await ShowContactService(contactId);
return res.status(200).json(contact);
};
export const update = async (
req: Request,
res: Response
): Promise<Response> => {
const contactData: ContactData = req.body;
const schema = Yup.object().shape({
name: Yup.string(),
number: Yup.string().matches(
/^\d+$/,
"Invalid number format. Only numbers is allowed."
)
});
try {
await schema.validate(contactData);
} catch (err) {
throw new AppError(err.message);
}
await CheckIsValidContact(contactData.number);
const { contactId } = req.params;
const contact = await UpdateContactService({ contactData, contactId });
const io = getIO();
io.emit("contact", {
action: "update",
contact
});
return res.status(200).json(contact);
};
export const remove = async (
req: Request,
res: Response
): Promise<Response> => {
const { contactId } = req.params;
await DeleteContactService(contactId);
const io = getIO();
io.emit("contact", {
action: "delete",
contactId
});
return res.status(200).json({ message: "Contact deleted" });
};

View File

@@ -0,0 +1,9 @@
import { Request, Response } from "express";
import ImportContactsService from "../services/WbotServices/ImportContactsService";
export const store = async (req: Request, res: Response): Promise<Response> => {
const userId:number = parseInt(req.user.id);
await ImportContactsService(userId);
return res.status(200).json({ message: "contacts imported" });
};

View File

@@ -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<Response> => {
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<Response> => {
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<Response> => {
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();
};

View File

@@ -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<Response> => {
const queues = await ListQueuesService();
return res.status(200).json(queues);
};
export const store = async (req: Request, res: Response): Promise<Response> => {
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<Response> => {
const { queueId } = req.params;
const queue = await ShowQueueService(queueId);
return res.status(200).json(queue);
};
export const update = async (
req: Request,
res: Response
): Promise<Response> => {
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<Response> => {
const { queueId } = req.params;
await DeleteQueueService(queueId);
const io = getIO();
io.emit("queue", {
action: "delete",
queueId: +queueId
});
return res.status(200).send();
};

View File

@@ -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<Response> => {
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<Response> => {
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<Response> => {
const { quickAnswerId } = req.params;
const quickAnswer = await ShowQuickAnswerService(quickAnswerId);
return res.status(200).json(quickAnswer);
};
export const update = async (
req: Request,
res: Response
): Promise<Response> => {
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<Response> => {
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" });
};

View File

@@ -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<Response> => {
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<Response> => {
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<Response> => {
res.clearCookie("jrt");
return res.send();
};

View File

@@ -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<Response> => {
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<Response> => {
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);
};

View File

@@ -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<Response> => {
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<Response> => {
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<Response> => {
const { ticketId } = req.params;
const contact = await ShowTicketService(ticketId);
return res.status(200).json(contact);
};
export const update = async (
req: Request,
res: Response
): Promise<Response> => {
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<Response> => {
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" });
};

View File

@@ -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<Response> => {
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<Response> => {
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<Response> => {
const { userId } = req.params;
const user = await ShowUserService(userId);
return res.status(200).json(user);
};
export const update = async (
req: Request,
res: Response
): Promise<Response> => {
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<Response> => {
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" });
};

View File

@@ -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<Response> => {
const whatsapps = await ListWhatsAppsService();
return res.status(200).json(whatsapps);
};
export const store = async (req: Request, res: Response): Promise<Response> => {
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<Response> => {
const { whatsappId } = req.params;
const whatsapp = await ShowWhatsAppService(whatsappId);
return res.status(200).json(whatsapp);
};
export const update = async (
req: Request,
res: Response
): Promise<Response> => {
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<Response> => {
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." });
};

View File

@@ -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<Response> => {
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<Response> => {
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<Response> => {
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 };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<void> => {
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;

View File

@@ -0,0 +1,16 @@
import Setting from "../models/Setting";
import AppError from "../errors/AppError";
const CheckSettings = async (key: string): Promise<string> => {
const setting = await Setting.findOne({
where: { key }
});
if (!setting) {
throw new AppError("ERR_NO_SETTING_FOUND", 404);
}
return setting.value;
};
export default CheckSettings;

View File

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

View File

@@ -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<void>; (...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 };

View File

@@ -0,0 +1,26 @@
import AppError from "../errors/AppError";
import Whatsapp from "../models/Whatsapp";
import GetDefaultWhatsAppByUser from "./GetDefaultWhatsAppByUser";
const GetDefaultWhatsApp = async (
userId?: number
): Promise<Whatsapp> => {
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;

View File

@@ -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<Whatsapp | null> => {
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;

View File

@@ -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<Session> => {
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;

View File

@@ -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<WbotMessage> => {
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<void | WbotMessage> => {
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;

View File

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

View File

@@ -0,0 +1,5 @@
import { Response } from "express";
export const SendRefreshToken = (res: Response, token: string): void => {
res.cookie("jrt", token, { httpOnly: true });
};

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More