diff --git a/backend/src/app.ts b/backend/src/app.ts index 95c5f67..dd8155b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -32,11 +32,7 @@ app.use(Sentry.Handlers.errorHandler()); app.use(async (err: Error, req: Request, res: Response, _: NextFunction) => { if (err instanceof AppError) { - if (err.statusCode === 403) { - logger.warn(err); - } else { - logger.error(err); - } + logger.warn(err); return res.status(err.statusCode).json({ error: err.message }); } diff --git a/backend/src/config/auth.ts b/backend/src/config/auth.ts index 6f8c5fd..706f3d5 100644 --- a/backend/src/config/auth.ts +++ b/backend/src/config/auth.ts @@ -1,6 +1,6 @@ export default { secret: process.env.JWT_SECRET || "mysecret", - expiresIn: "15m", + expiresIn: "15d", refreshSecret: process.env.JWT_REFRESH_SECRET || "myanothersecret", refreshExpiresIn: "7d" }; 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/SessionController.ts b/backend/src/controllers/SessionController.ts index b9dcf8e..3dc32c1 100644 --- a/backend/src/controllers/SessionController.ts +++ b/backend/src/controllers/SessionController.ts @@ -8,7 +8,7 @@ import { RefreshTokenService } from "../services/AuthServices/RefreshTokenServic export const store = async (req: Request, res: Response): Promise => { const { email, password } = req.body; - const { token, user, refreshToken } = await AuthUserService({ + const { token, serializedUser, refreshToken } = await AuthUserService({ email, password }); @@ -17,9 +17,7 @@ export const store = async (req: Request, res: Response): Promise => { return res.status(200).json({ token, - username: user.name, - profile: user.profile, - userId: user.id + user: serializedUser }); }; @@ -33,9 +31,9 @@ export const update = async ( throw new AppError("ERR_SESSION_EXPIRED", 401); } - const { newToken, refreshToken } = await RefreshTokenService(token); + const { user, newToken, refreshToken } = await RefreshTokenService(token); SendRefreshToken(res, refreshToken); - return res.json({ token: newToken }); + return res.json({ token: newToken, user }); }; diff --git a/backend/src/controllers/TicketController.ts b/backend/src/controllers/TicketController.ts index d90020d..93b03c1 100644 --- a/backend/src/controllers/TicketController.ts +++ b/backend/src/controllers/TicketController.ts @@ -14,6 +14,7 @@ type IndexQuery = { date: string; showAll: string; withUnreadMessages: string; + queueIds: string; }; interface TicketData { @@ -29,11 +30,18 @@ export const index = async (req: Request, res: Response): Promise => { 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, @@ -41,6 +49,7 @@ export const index = async (req: Request, res: Response): Promise => { date, showAll, userId, + queueIds, withUnreadMessages }); @@ -54,7 +63,7 @@ export const store = async (req: Request, res: Response): Promise => { const io = getIO(); io.to(ticket.status).emit("ticket", { - action: "create", + action: "update", ticket }); @@ -91,7 +100,7 @@ export const update = async ( } io.to(ticket.status).to("notification").to(ticketId).emit("ticket", { - action: "updateStatus", + action: "update", ticket }); diff --git a/backend/src/controllers/UserController.ts b/backend/src/controllers/UserController.ts index e1c8a19..06d329d 100644 --- a/backend/src/controllers/UserController.ts +++ b/backend/src/controllers/UserController.ts @@ -27,7 +27,7 @@ export const index = async (req: Request, res: Response): Promise => { }; export const store = async (req: Request, res: Response): Promise => { - const { email, password, name, profile } = req.body; + const { email, password, name, profile, queueIds } = req.body; if ( req.url === "/signup" && @@ -42,7 +42,8 @@ export const store = async (req: Request, res: Response): Promise => { email, password, name, - profile + profile, + queueIds }); const io = getIO(); diff --git a/backend/src/controllers/WhatsAppController.ts b/backend/src/controllers/WhatsAppController.ts index 531bfe7..13097d3 100644 --- a/backend/src/controllers/WhatsAppController.ts +++ b/backend/src/controllers/WhatsAppController.ts @@ -11,6 +11,8 @@ import UpdateWhatsAppService from "../services/WhatsappService/UpdateWhatsAppSer interface WhatsappData { name: string; + queueIds: number[]; + greetingMessage?: string; status?: string; isDefault?: boolean; } @@ -22,15 +24,23 @@ export const index = async (req: Request, res: Response): Promise => { }; export const store = async (req: Request, res: Response): Promise => { - const { name, status, isDefault }: WhatsappData = req.body; + const { + name, + status, + isDefault, + greetingMessage, + queueIds + }: WhatsappData = req.body; const { whatsapp, oldDefaultWhatsapp } = await CreateWhatsAppService({ name, status, - isDefault + isDefault, + greetingMessage, + queueIds }); - StartWhatsAppSession(whatsapp); + // StartWhatsAppSession(whatsapp); const io = getIO(); io.emit("whatsapp", { diff --git a/backend/src/database/index.ts b/backend/src/database/index.ts index 9fb351d..b62e840 100644 --- a/backend/src/database/index.ts +++ b/backend/src/database/index.ts @@ -6,6 +6,9 @@ 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"; // eslint-disable-next-line const dbConfig = require("../config/database"); @@ -20,7 +23,10 @@ const models = [ Message, Whatsapp, ContactCustomField, - Setting + Setting, + Queue, + WhatsappQueue, + UserQueue ]; sequelize.addModels(models); 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/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/SerializeUser.ts b/backend/src/helpers/SerializeUser.ts new file mode 100644 index 0000000..3802500 --- /dev/null +++ b/backend/src/helpers/SerializeUser.ts @@ -0,0 +1,20 @@ +import Queue from "../models/Queue"; +import User from "../models/User"; + +interface SerializedUser { + id: number; + name: string; + email: string; + profile: string; + queues: Queue[]; +} + +export const SerializeUser = (user: User): SerializedUser => { + return { + id: user.id, + name: user.name, + email: user.email, + profile: user.profile, + queues: user.queues + }; +}; diff --git a/backend/src/middleware/isAuth.ts b/backend/src/middleware/isAuth.ts index ae70f05..83cae2a 100644 --- a/backend/src/middleware/isAuth.ts +++ b/backend/src/middleware/isAuth.ts @@ -16,7 +16,7 @@ const isAuth = (req: Request, res: Response, next: NextFunction): void => { const authHeader = req.headers.authorization; if (!authHeader) { - throw new AppError("Token was not provided.", 403); + throw new AppError("ERR_SESSION_EXPIRED", 401); } const [, token] = authHeader.split(" "); diff --git a/backend/src/models/Queue.ts b/backend/src/models/Queue.ts new file mode 100644 index 0000000..334abb2 --- /dev/null +++ b/backend/src/models/Queue.ts @@ -0,0 +1,53 @@ +import { + Table, + Column, + CreatedAt, + UpdatedAt, + Model, + PrimaryKey, + AutoIncrement, + AllowNull, + Unique, + BelongsToMany, + HasMany +} 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/Ticket.ts b/backend/src/models/Ticket.ts index 80d94cf..8de4375 100644 --- a/backend/src/models/Ticket.ts +++ b/backend/src/models/Ticket.ts @@ -14,6 +14,7 @@ import { import Contact from "./Contact"; import Message from "./Message"; +import Queue from "./Queue"; import User from "./User"; import Whatsapp from "./Whatsapp"; @@ -64,6 +65,13 @@ class Ticket extends Model { @BelongsTo(() => Whatsapp) whatsapp: Whatsapp; + @ForeignKey(() => Queue) + @Column + queueId: number; + + @BelongsTo(() => Queue) + queue: Queue; + @HasMany(() => Message) messages: Message[]; } diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts index fe840fc..9be664b 100644 --- a/backend/src/models/User.ts +++ b/backend/src/models/User.ts @@ -10,10 +10,13 @@ import { PrimaryKey, AutoIncrement, Default, - HasMany + HasMany, + BelongsToMany } from "sequelize-typescript"; import { hash, compare } from "bcryptjs"; import Ticket from "./Ticket"; +import Queue from "./Queue"; +import UserQueue from "./UserQueue"; @Table class User extends Model { @@ -51,6 +54,9 @@ class User extends Model { @HasMany(() => Ticket) tickets: Ticket[]; + @BelongsToMany(() => Queue, () => UserQueue) + queues: Queue[]; + @BeforeUpdate @BeforeCreate static hashPassword = async (instance: User): Promise => { 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 index 099b0db..3c76a0c 100644 --- a/backend/src/models/Whatsapp.ts +++ b/backend/src/models/Whatsapp.ts @@ -10,9 +10,12 @@ import { Default, AllowNull, HasMany, - Unique + Unique, + BelongsToMany } from "sequelize-typescript"; +import Queue from "./Queue"; import Ticket from "./Ticket"; +import WhatsappQueue from "./WhatsappQueue"; @Table class Whatsapp extends Model { @@ -44,6 +47,9 @@ class Whatsapp extends Model { @Column retries: number; + @Column(DataType.TEXT) + greetingMessage: string; + @Default(false) @AllowNull @Column @@ -57,6 +63,12 @@ class Whatsapp extends Model { @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/index.ts b/backend/src/routes/index.ts index 2abafa8..7043f61 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -8,6 +8,7 @@ import ticketRoutes from "./ticketRoutes"; import whatsappRoutes from "./whatsappRoutes"; import messageRoutes from "./messageRoutes"; import whatsappSessionRoutes from "./whatsappSessionRoutes"; +import queueRoutes from "./queueRoutes"; const routes = Router(); @@ -20,5 +21,6 @@ routes.use(whatsappRoutes); routes.use(messageRoutes); routes.use(messageRoutes); routes.use(whatsappSessionRoutes); +routes.use(queueRoutes); export default routes; 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/services/AuthServices/RefreshTokenService.ts b/backend/src/services/AuthServices/RefreshTokenService.ts index fa7893c..78ed933 100644 --- a/backend/src/services/AuthServices/RefreshTokenService.ts +++ b/backend/src/services/AuthServices/RefreshTokenService.ts @@ -1,4 +1,6 @@ import { verify } from "jsonwebtoken"; + +import User from "../../models/User"; import AppError from "../../errors/AppError"; import ShowUserService from "../UserServices/ShowUserService"; import authConfig from "../../config/auth"; @@ -13,6 +15,7 @@ interface RefreshTokenPayload { } interface Response { + user: User; newToken: string; refreshToken: string; } @@ -37,5 +40,5 @@ export const RefreshTokenService = async (token: string): Promise => { const newToken = createAccessToken(user); const refreshToken = createRefreshToken(user); - return { newToken, refreshToken }; + return { user, newToken, refreshToken }; }; diff --git a/backend/src/services/MessageServices/CreateMessageService.ts b/backend/src/services/MessageServices/CreateMessageService.ts index 857ec5c..67fd33b 100644 --- a/backend/src/services/MessageServices/CreateMessageService.ts +++ b/backend/src/services/MessageServices/CreateMessageService.ts @@ -27,7 +27,7 @@ const CreateMessageService = async ({ { model: Ticket, as: "ticket", - include: ["contact"] + include: ["contact", "queue"] }, { model: Message, 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/TicketServices/FindOrCreateTicketService.ts b/backend/src/services/TicketServices/FindOrCreateTicketService.ts index 9d0ba89..bf4c2b0 100644 --- a/backend/src/services/TicketServices/FindOrCreateTicketService.ts +++ b/backend/src/services/TicketServices/FindOrCreateTicketService.ts @@ -16,23 +16,19 @@ const FindOrCreateTicketService = async ( [Op.or]: ["open", "pending"] }, contactId: groupContact ? groupContact.id : contact.id - }, - include: ["contact"] + } }); if (ticket) { await ticket.update({ unreadMessages }); - - return ticket; } - if (groupContact) { + if (!ticket && groupContact) { ticket = await Ticket.findOne({ where: { contactId: groupContact.id }, - order: [["updatedAt", "DESC"]], - include: ["contact"] + order: [["updatedAt", "DESC"]] }); if (ticket) { @@ -41,10 +37,10 @@ const FindOrCreateTicketService = async ( userId: null, unreadMessages }); - - return ticket; } - } else { + } + + if (!ticket && !groupContact) { ticket = await Ticket.findOne({ where: { updatedAt: { @@ -52,8 +48,7 @@ const FindOrCreateTicketService = async ( }, contactId: contact.id }, - order: [["updatedAt", "DESC"]], - include: ["contact"] + order: [["updatedAt", "DESC"]] }); if (ticket) { @@ -62,20 +57,20 @@ const FindOrCreateTicketService = async ( userId: null, unreadMessages }); - - return ticket; } } - const { id } = await Ticket.create({ - contactId: groupContact ? groupContact.id : contact.id, - status: "pending", - isGroup: !!groupContact, - unreadMessages, - whatsappId - }); + if (!ticket) { + ticket = await Ticket.create({ + contactId: groupContact ? groupContact.id : contact.id, + status: "pending", + isGroup: !!groupContact, + unreadMessages, + whatsappId + }); + } - ticket = await ShowTicketService(id); + ticket = await ShowTicketService(ticket.id); return ticket; }; diff --git a/backend/src/services/TicketServices/ListTicketsService.ts b/backend/src/services/TicketServices/ListTicketsService.ts index 7351102..2959826 100644 --- a/backend/src/services/TicketServices/ListTicketsService.ts +++ b/backend/src/services/TicketServices/ListTicketsService.ts @@ -4,6 +4,8 @@ import { startOfDay, endOfDay, parseISO } from "date-fns"; import Ticket from "../../models/Ticket"; import Contact from "../../models/Contact"; import Message from "../../models/Message"; +import Queue from "../../models/Queue"; +import ShowUserService from "../UserServices/ShowUserService"; interface Request { searchParam?: string; @@ -13,6 +15,7 @@ interface Request { showAll?: string; userId: string; withUnreadMessages?: string; + queueIds: number[]; } interface Response { @@ -24,6 +27,7 @@ interface Response { const ListTicketsService = async ({ searchParam = "", pageNumber = "1", + queueIds, status, date, showAll, @@ -31,7 +35,8 @@ const ListTicketsService = async ({ withUnreadMessages }: Request): Promise => { let whereCondition: Filterable["where"] = { - [Op.or]: [{ userId }, { status: "pending" }] + [Op.or]: [{ userId }, { status: "pending" }], + queueId: { [Op.or]: [queueIds, null] } }; let includeCondition: Includeable[]; @@ -40,11 +45,16 @@ const ListTicketsService = async ({ model: Contact, as: "contact", attributes: ["id", "name", "number", "profilePicUrl"] + }, + { + model: Queue, + as: "queue", + attributes: ["id", "name", "color"] } ]; if (showAll === "true") { - whereCondition = {}; + whereCondition = { queueId: { [Op.or]: [queueIds, null] } }; } if (status) { @@ -76,10 +86,11 @@ const ListTicketsService = async ({ ]; whereCondition = { + ...whereCondition, [Op.or]: [ { "$contact.name$": where( - fn("LOWER", col("name")), + fn("LOWER", col("contact.name")), "LIKE", `%${sanitizedSearchParam}%` ) @@ -106,18 +117,14 @@ const ListTicketsService = async ({ } if (withUnreadMessages === "true") { - includeCondition = [ - ...includeCondition, - { - model: Message, - as: "messages", - attributes: [], - where: { - read: false, - fromMe: false - } - } - ]; + 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 = 20; diff --git a/backend/src/services/TicketServices/ShowTicketService.ts b/backend/src/services/TicketServices/ShowTicketService.ts index dd0cc30..5efab0c 100644 --- a/backend/src/services/TicketServices/ShowTicketService.ts +++ b/backend/src/services/TicketServices/ShowTicketService.ts @@ -2,6 +2,7 @@ import Ticket from "../../models/Ticket"; import AppError from "../../errors/AppError"; import Contact from "../../models/Contact"; import User from "../../models/User"; +import Queue from "../../models/Queue"; const ShowTicketService = async (id: string | number): Promise => { const ticket = await Ticket.findByPk(id, { @@ -16,6 +17,11 @@ const ShowTicketService = async (id: string | number): Promise => { model: User, as: "user", attributes: ["id", "name"] + }, + { + model: Queue, + as: "queue", + attributes: ["id", "name", "color"] } ] }); diff --git a/backend/src/services/TicketServices/UpdateTicketService.ts b/backend/src/services/TicketServices/UpdateTicketService.ts index ed61b64..d7f7bfa 100644 --- a/backend/src/services/TicketServices/UpdateTicketService.ts +++ b/backend/src/services/TicketServices/UpdateTicketService.ts @@ -1,9 +1,7 @@ -import AppError from "../../errors/AppError"; import CheckContactOpenTickets from "../../helpers/CheckContactOpenTickets"; import SetTicketMessagesAsRead from "../../helpers/SetTicketMessagesAsRead"; -import Contact from "../../models/Contact"; import Ticket from "../../models/Ticket"; -import User from "../../models/User"; +import ShowTicketService from "./ShowTicketService"; interface TicketData { status?: string; @@ -27,25 +25,7 @@ const UpdateTicketService = async ({ }: Request): Promise => { const { status, userId } = ticketData; - const ticket = await Ticket.findOne({ - where: { id: ticketId }, - include: [ - { - model: Contact, - as: "contact", - attributes: ["id", "name", "number", "profilePicUrl"] - }, - { - model: User, - as: "user", - attributes: ["id", "name"] - } - ] - }); - - if (!ticket) { - throw new AppError("ERR_NO_TICKET_FOUND", 404); - } + const ticket = await ShowTicketService(ticketId); await SetTicketMessagesAsRead(ticket); diff --git a/backend/src/services/UserServices/AuthUserSerice.ts b/backend/src/services/UserServices/AuthUserSerice.ts index 38d18dc..f198a7c 100644 --- a/backend/src/services/UserServices/AuthUserSerice.ts +++ b/backend/src/services/UserServices/AuthUserSerice.ts @@ -4,6 +4,16 @@ 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; @@ -11,7 +21,7 @@ interface Request { } interface Response { - user: User; + serializedUser: SerializedUser; token: string; refreshToken: string; } @@ -21,7 +31,8 @@ const AuthUserService = async ({ password }: Request): Promise => { const user = await User.findOne({ - where: { email } + where: { email }, + include: ["queues"] }); if (!user) { @@ -35,8 +46,10 @@ const AuthUserService = async ({ const token = createAccessToken(user); const refreshToken = createRefreshToken(user); + const serializedUser = SerializeUser(user); + return { - user, + serializedUser, token, refreshToken }; diff --git a/backend/src/services/UserServices/CreateUserService.ts b/backend/src/services/UserServices/CreateUserService.ts index c48b6d0..e277085 100644 --- a/backend/src/services/UserServices/CreateUserService.ts +++ b/backend/src/services/UserServices/CreateUserService.ts @@ -1,12 +1,14 @@ 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; } @@ -21,6 +23,7 @@ const CreateUserService = async ({ email, password, name, + queueIds = [], profile = "admin" }: Request): Promise => { const schema = Yup.object().shape({ @@ -47,19 +50,21 @@ const CreateUserService = async ({ throw new AppError(err.message); } - const user = await User.create({ - email, - password, - name, - profile - }); + const user = await User.create( + { + email, + password, + name, + profile + }, + { include: ["queues"] } + ); - const serializedUser = { - id: user.id, - name: user.name, - email: user.email, - profile: user.profile - }; + await user.$set("queues", queueIds); + + await user.reload(); + + const serializedUser = SerializeUser(user); return serializedUser; }; diff --git a/backend/src/services/UserServices/ListUsersService.ts b/backend/src/services/UserServices/ListUsersService.ts index 24bd8f0..fb8a202 100644 --- a/backend/src/services/UserServices/ListUsersService.ts +++ b/backend/src/services/UserServices/ListUsersService.ts @@ -1,4 +1,5 @@ import { Sequelize, Op } from "sequelize"; +import Queue from "../../models/Queue"; import User from "../../models/User"; interface Request { @@ -19,8 +20,8 @@ const ListUsersService = async ({ const whereCondition = { [Op.or]: [ { - name: Sequelize.where( - Sequelize.fn("LOWER", Sequelize.col("name")), + "$User.name$": Sequelize.where( + Sequelize.fn("LOWER", Sequelize.col("User.name")), "LIKE", `%${searchParam.toLowerCase()}%` ) @@ -33,10 +34,13 @@ const ListUsersService = async ({ const { count, rows: users } = await User.findAndCountAll({ where: whereCondition, - attributes: ["name", "id", "email", "profile"], + attributes: ["name", "id", "email", "profile", "createdAt"], limit, offset, - order: [["createdAt", "DESC"]] + order: [["createdAt", "DESC"]], + include: [ + { model: Queue, as: "queues", attributes: ["id", "name", "color"] } + ] }); const hasMore = count > offset + users.length; diff --git a/backend/src/services/UserServices/ShowUserService.ts b/backend/src/services/UserServices/ShowUserService.ts index bba0242..3052a2e 100644 --- a/backend/src/services/UserServices/ShowUserService.ts +++ b/backend/src/services/UserServices/ShowUserService.ts @@ -1,9 +1,13 @@ import User from "../../models/User"; import AppError from "../../errors/AppError"; +import Queue from "../../models/Queue"; const ShowUserService = async (id: string | number): Promise => { const user = await User.findByPk(id, { - attributes: ["name", "id", "email", "profile", "tokenVersion"] + attributes: ["name", "id", "email", "profile", "tokenVersion"], + include: [ + { model: Queue, as: "queues", attributes: ["id", "name", "color"] } + ] }); if (!user) { diff --git a/backend/src/services/UserServices/UpdateUserService.ts b/backend/src/services/UserServices/UpdateUserService.ts index e548645..114d557 100644 --- a/backend/src/services/UserServices/UpdateUserService.ts +++ b/backend/src/services/UserServices/UpdateUserService.ts @@ -1,13 +1,14 @@ import * as Yup from "yup"; import AppError from "../../errors/AppError"; -import User from "../../models/User"; +import ShowUserService from "./ShowUserService"; interface UserData { email?: string; password?: string; name?: string; profile?: string; + queueIds?: number[]; } interface Request { @@ -26,14 +27,7 @@ const UpdateUserService = async ({ userData, userId }: Request): Promise => { - const user = await User.findOne({ - where: { id: userId }, - attributes: ["name", "id", "email", "profile"] - }); - - if (!user) { - throw new AppError("ERR_NO_USER_FOUND", 404); - } + const user = await ShowUserService(userId); const schema = Yup.object().shape({ name: Yup.string().min(2), @@ -42,7 +36,7 @@ const UpdateUserService = async ({ password: Yup.string() }); - const { email, password, profile, name } = userData; + const { email, password, profile, name, queueIds = [] } = userData; try { await schema.validate({ email, password, profile, name }); @@ -57,11 +51,16 @@ const UpdateUserService = async ({ name }); + await user.$set("queues", queueIds); + + await user.reload(); + const serializedUser = { id: user.id, name: user.name, email: user.email, - profile: user.profile + profile: user.profile, + queues: user.queues }; return serializedUser; diff --git a/backend/src/services/WbotServices/StartAllWhatsAppsSessions.ts b/backend/src/services/WbotServices/StartAllWhatsAppsSessions.ts index a472f9f..9e5e935 100644 --- a/backend/src/services/WbotServices/StartAllWhatsAppsSessions.ts +++ b/backend/src/services/WbotServices/StartAllWhatsAppsSessions.ts @@ -1,8 +1,8 @@ -import Whatsapp from "../../models/Whatsapp"; +import ListWhatsAppsService from "../WhatsappService/ListWhatsAppsService"; import { StartWhatsAppSession } from "./StartWhatsAppSession"; export const StartAllWhatsAppsSessions = async (): Promise => { - const whatsapps = await Whatsapp.findAll(); + const whatsapps = await ListWhatsAppsService(); if (whatsapps.length > 0) { whatsapps.forEach(whatsapp => { StartWhatsAppSession(whatsapp); diff --git a/backend/src/services/WbotServices/wbotMessageListener.ts b/backend/src/services/WbotServices/wbotMessageListener.ts index 4b03abb..3651b65 100644 --- a/backend/src/services/WbotServices/wbotMessageListener.ts +++ b/backend/src/services/WbotServices/wbotMessageListener.ts @@ -19,6 +19,8 @@ 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"; interface Session extends Client { id?: number; @@ -126,6 +128,60 @@ const verifyMessage = async ( await CreateMessageService({ messageData }); }; +const verifyQueue = async ( + wbot: Session, + msg: WbotMessage, + ticket: Ticket, + contact: Contact +) => { + const { queues, greetingMessage } = await ShowWhatsAppService(wbot.id!); + + if (queues.length === 1) { + await ticket.$set("queue", queues[0]); + // TODO sendTicketQueueUpdate to frontend + + return; + } + + const selectedOption = msg.body[0]; + + const choosenQueue = queues[+selectedOption - 1]; + + if (choosenQueue) { + await ticket.$set("queue", choosenQueue); + + const body = `\u200e${choosenQueue.greetingMessage}`; + + const sentMessage = await wbot.sendMessage(`${contact.number}@c.us`, body); + + await verifyMessage(sentMessage, ticket, contact); + + // TODO sendTicketQueueUpdate to frontend + } else { + let options = ""; + + queues.forEach((queue, index) => { + options += `*${index + 1}* - ${queue.name}\n`; + }); + + const body = `\u200e${greetingMessage}\n${options}`; + + const debouncedSentMessage = debounce( + async () => { + const sentMessage = await wbot.sendMessage( + `${contact.number}@c.us`, + body + ); + verifyMessage(sentMessage, ticket, contact); + }, + 3000, + ticket.id + ); + + debouncedSentMessage(); + } +}; + const isValidMsg = (msg: WbotMessage): boolean => { if (msg.from === "status@broadcast") return false; if ( @@ -146,66 +202,66 @@ const handleMessage = async ( msg: WbotMessage, wbot: Session ): Promise => { - return new Promise((resolve, reject) => { - (async () => { - if (!isValidMsg(msg)) { - return; + if (!isValidMsg(msg)) { + return; + } + + try { + let msgContact: WbotContact; + let groupContact: Contact | undefined; + + if (msg.fromMe) { + // messages sent automatically by wbot have a special character in front of it + // if so, this message was already been stored in database; + if (/\u200e/.test(msg.body[0])) return; + + // media messages sent from me from cell phone, first comes with "hasMedia = false" and type = "image/ptt/etc" + // in this case, return and let this message be handled by "media_uploaded" event, when it will have "hasMedia = true" + + if (!msg.hasMedia && msg.type !== "chat" && msg.type !== "vcard") return; + + msgContact = await wbot.getContactById(msg.to); + } else { + msgContact = await msg.getContact(); + } + + const chat = await msg.getChat(); + + if (chat.isGroup) { + let msgGroupContact; + + if (msg.fromMe) { + msgGroupContact = await wbot.getContactById(msg.to); + } else { + msgGroupContact = await wbot.getContactById(msg.from); } - try { - let msgContact: WbotContact; - let groupContact: Contact | undefined; + groupContact = await verifyContact(msgGroupContact); + } - if (msg.fromMe) { - // 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" + const unreadMessages = msg.fromMe ? 0 : chat.unreadCount; - if (!msg.hasMedia && msg.type !== "chat" && msg.type !== "vcard") - return; + const contact = await verifyContact(msgContact); + const ticket = await FindOrCreateTicketService( + contact, + wbot.id!, + unreadMessages, + groupContact + ); - msgContact = await wbot.getContactById(msg.to); - } else { - msgContact = await msg.getContact(); - } + if (msg.hasMedia) { + await verifyMediaMessage(msg, ticket, contact); + } else { + await verifyMessage(msg, ticket, contact); + } - 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 unreadMessages = msg.fromMe ? 0 : chat.unreadCount; - - const contact = await verifyContact(msgContact); - const ticket = await FindOrCreateTicketService( - contact, - wbot.id!, - unreadMessages, - groupContact - ); - - if (msg.hasMedia) { - await verifyMediaMessage(msg, ticket, contact); - resolve(); - } else { - await verifyMessage(msg, ticket, contact); - resolve(); - } - } catch (err) { - Sentry.captureException(err); - logger.error(`Error handling whatsapp message: Err: ${err}`); - reject(err); - } - })(); - }); + if (!ticket.queue && !chat.isGroup && !msg.fromMe && !ticket.userId) { + await verifyQueue(wbot, msg, ticket, contact); + } + } catch (err) { + Sentry.captureException(err); + logger.error(`Error handling whatsapp message: Err: ${err}`); + } }; const handleMsgAck = async (msg: WbotMessage, ack: MessageAck) => { 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 index 118343e..f653d49 100644 --- a/backend/src/services/WhatsappService/CreateWhatsAppService.ts +++ b/backend/src/services/WhatsappService/CreateWhatsAppService.ts @@ -2,9 +2,12 @@ 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; status?: string; isDefault?: boolean; } @@ -17,6 +20,8 @@ interface Response { const CreateWhatsAppService = async ({ name, status = "OPENING", + queueIds = [], + greetingMessage, isDefault = false }: Request): Promise => { const schema = Yup.object().shape({ @@ -62,11 +67,21 @@ const CreateWhatsAppService = async ({ } } - const whatsapp = await Whatsapp.create({ - name, - status, - isDefault - }); + if (queueIds.length > 1 && !greetingMessage) { + throw new AppError("ERR_WAPP_GREETING_REQUIRED"); + } + + const whatsapp = await Whatsapp.create( + { + name, + status, + greetingMessage, + isDefault + }, + { include: ["queues"] } + ); + + await AssociateWhatsappQueue(whatsapp, queueIds); return { whatsapp, oldDefaultWhatsapp }; }; diff --git a/backend/src/services/WhatsappService/ListWhatsAppsService.ts b/backend/src/services/WhatsappService/ListWhatsAppsService.ts index 2213530..3d29c2c 100644 --- a/backend/src/services/WhatsappService/ListWhatsAppsService.ts +++ b/backend/src/services/WhatsappService/ListWhatsAppsService.ts @@ -1,7 +1,16 @@ +import Queue from "../../models/Queue"; import Whatsapp from "../../models/Whatsapp"; const ListWhatsAppsService = async (): Promise => { - const whatsapps = await Whatsapp.findAll(); + const whatsapps = await Whatsapp.findAll({ + include: [ + { + model: Queue, + as: "queues", + attributes: ["id", "name", "color", "greetingMessage"] + } + ] + }); return whatsapps; }; diff --git a/backend/src/services/WhatsappService/ShowWhatsAppService.ts b/backend/src/services/WhatsappService/ShowWhatsAppService.ts index 6a203a9..235ef17 100644 --- a/backend/src/services/WhatsappService/ShowWhatsAppService.ts +++ b/backend/src/services/WhatsappService/ShowWhatsAppService.ts @@ -1,8 +1,18 @@ 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); + 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); diff --git a/backend/src/services/WhatsappService/UpdateWhatsAppService.ts b/backend/src/services/WhatsappService/UpdateWhatsAppService.ts index 2fe79cc..4d96889 100644 --- a/backend/src/services/WhatsappService/UpdateWhatsAppService.ts +++ b/backend/src/services/WhatsappService/UpdateWhatsAppService.ts @@ -3,12 +3,16 @@ 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; + queueIds?: number[]; } interface Request { @@ -26,11 +30,18 @@ const UpdateWhatsAppService = async ({ whatsappId }: Request): Promise => { const schema = Yup.object().shape({ - name: Yup.string().min(2), + name: Yup.string().min(2).required(), isDefault: Yup.boolean() }); - const { name, status, isDefault, session } = whatsappData; + const { + name, + status, + isDefault, + session, + greetingMessage, + queueIds = [] + } = whatsappData; try { await schema.validate({ name, status, isDefault }); @@ -38,6 +49,10 @@ const UpdateWhatsAppService = async ({ throw new AppError(err.message); } + if (queueIds.length > 1 && !greetingMessage) { + throw new AppError("ERR_WAPP_GREETING_REQUIRED"); + } + let oldDefaultWhatsapp: Whatsapp | null = null; if (isDefault) { @@ -49,20 +64,18 @@ const UpdateWhatsAppService = async ({ } } - const whatsapp = await Whatsapp.findOne({ - where: { id: whatsappId } - }); + const whatsapp = await ShowWhatsAppService(whatsappId); - if (!whatsapp) { - throw new AppError("ERR_NO_WAPP_FOUND", 404); - } await whatsapp.update({ name, status, session, + greetingMessage, isDefault }); + await AssociateWhatsappQueue(whatsapp, queueIds); + return { whatsapp, oldDefaultWhatsapp }; }; diff --git a/frontend/package.json b/frontend/package.json index 3725edd..c316de3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "mic-recorder-to-mp3": "^2.2.2", "qrcode.react": "^1.0.0", "react": "^16.13.1", + "react-color": "^2.19.3", "react-dom": "^16.13.1", "react-modal-image": "^2.5.0", "react-router-dom": "^5.2.0", diff --git a/frontend/public/index.html b/frontend/public/index.html index dc5f765..59581ba 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -3,14 +3,18 @@ WhaTicket + diff --git a/frontend/src/accessRules.js b/frontend/src/accessRules.js new file mode 100644 index 0000000..423bb03 --- /dev/null +++ b/frontend/src/accessRules.js @@ -0,0 +1,18 @@ +const rules = { + user: { + static: [], + }, + + admin: { + static: [ + "drawer-admin-items:view", + "tickets-manager:showall", + "user-modal:editProfile", + "user-modal:editQueues", + "ticket-options:deleteTicket", + "contacts-page:deleteContact", + ], + }, +}; + +export default rules; diff --git a/frontend/src/components/Can/index.js b/frontend/src/components/Can/index.js new file mode 100644 index 0000000..9d9e27a --- /dev/null +++ b/frontend/src/components/Can/index.js @@ -0,0 +1,39 @@ +import rules from "../../accessRules"; + +const check = (rules, role, action, data) => { + const permissions = rules[role]; + if (!permissions) { + // role is not present in the rules + return false; + } + + const staticPermissions = permissions.static; + + if (staticPermissions && staticPermissions.includes(action)) { + // static rule not provided for action + return true; + } + + const dynamicPermissions = permissions.dynamic; + + if (dynamicPermissions) { + const permissionCondition = dynamicPermissions[action]; + if (!permissionCondition) { + // dynamic rule not provided for action + return false; + } + + return permissionCondition(data); + } + return false; +}; + +const Can = ({ role, perform, data, yes, no }) => + check(rules, role, perform, data) ? yes() : no(); + +Can.defaultProps = { + yes: () => null, + no: () => null, +}; + +export { Can }; diff --git a/frontend/src/components/ColorPicker/index.js b/frontend/src/components/ColorPicker/index.js new file mode 100644 index 0000000..fdd5dbd --- /dev/null +++ b/frontend/src/components/ColorPicker/index.js @@ -0,0 +1,25 @@ +import React, { useState } from "react"; + +import { GithubPicker } from "react-color"; + +const ColorPicker = ({ onChange, currentColor }) => { + const [color, setColor] = useState(currentColor); + + const handleChange = color => { + setColor(color.hex); + }; + + return ( +
+ onChange(color.hex)} + /> +
+ ); +}; + +export default ColorPicker; diff --git a/frontend/src/components/ConfirmationModal/index.js b/frontend/src/components/ConfirmationModal/index.js index ce5681d..ce340f2 100644 --- a/frontend/src/components/ConfirmationModal/index.js +++ b/frontend/src/components/ConfirmationModal/index.js @@ -8,11 +8,11 @@ import Typography from "@material-ui/core/Typography"; import { i18n } from "../../translate/i18n"; -const ConfirmationModal = ({ title, children, open, setOpen, onConfirm }) => { +const ConfirmationModal = ({ title, children, open, onClose, onConfirm }) => { return ( setOpen(false)} + onClose={() => onClose(false)} aria-labelledby="confirm-dialog" > {title} @@ -22,7 +22,7 @@ const ConfirmationModal = ({ title, children, open, setOpen, onConfirm }) => { + + + + )} + + + + ); +}; + +export default QueueModal; diff --git a/frontend/src/components/QueueSelect/index.js b/frontend/src/components/QueueSelect/index.js new file mode 100644 index 0000000..22b0529 --- /dev/null +++ b/frontend/src/components/QueueSelect/index.js @@ -0,0 +1,89 @@ +import React, { useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import InputLabel from "@material-ui/core/InputLabel"; +import MenuItem from "@material-ui/core/MenuItem"; +import FormControl from "@material-ui/core/FormControl"; +import Select from "@material-ui/core/Select"; +import Chip from "@material-ui/core/Chip"; +import toastError from "../../errors/toastError"; +import api from "../../services/api"; + +const useStyles = makeStyles(theme => ({ + chips: { + display: "flex", + flexWrap: "wrap", + }, + chip: { + margin: 2, + }, +})); + +const QueueSelect = ({ selectedQueueIds, onChange }) => { + const classes = useStyles(); + const [queues, setQueues] = useState([]); + + useEffect(() => { + (async () => { + try { + const { data } = await api.get("/queue"); + setQueues(data); + } catch (err) { + toastError(err); + } + })(); + }, []); + + const handleChange = event => { + onChange(event.target.value); + }; + + return ( +
+ + Filas + + +
+ ); +}; + +export default QueueSelect; diff --git a/frontend/src/components/Ticket/index.js b/frontend/src/components/Ticket/index.js index 87c04e7..c2cdd37 100644 --- a/frontend/src/components/Ticket/index.js +++ b/frontend/src/components/Ticket/index.js @@ -86,10 +86,11 @@ const Ticket = () => { useEffect(() => { const socket = openSocket(process.env.REACT_APP_BACKEND_URL); - socket.emit("joinChatBox", ticketId); + + socket.on("connect", () => socket.emit("joinChatBox", ticketId)); socket.on("ticket", data => { - if (data.action === "updateStatus") { + if (data.action === "update") { setTicket(data.ticket); } diff --git a/frontend/src/components/TicketActionButtons/index.js b/frontend/src/components/TicketActionButtons/index.js index 5a3a571..9cf58de 100644 --- a/frontend/src/components/TicketActionButtons/index.js +++ b/frontend/src/components/TicketActionButtons/index.js @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useContext, useState } from "react"; import { useHistory } from "react-router-dom"; import { makeStyles } from "@material-ui/core/styles"; @@ -10,6 +10,7 @@ import api from "../../services/api"; import TicketOptionsMenu from "../TicketOptionsMenu"; import ButtonWithSpinner from "../ButtonWithSpinner"; import toastError from "../../errors/toastError"; +import { AuthContext } from "../../context/Auth/AuthContext"; const useStyles = makeStyles(theme => ({ actionButtons: { @@ -26,10 +27,10 @@ const useStyles = makeStyles(theme => ({ const TicketActionButtons = ({ ticket }) => { const classes = useStyles(); const history = useHistory(); - const userId = +localStorage.getItem("userId"); const [anchorEl, setAnchorEl] = useState(null); const [loading, setLoading] = useState(false); const ticketOptionsMenuOpen = Boolean(anchorEl); + const { user } = useContext(AuthContext); const handleOpenTicketOptionsMenu = e => { setAnchorEl(e.currentTarget); @@ -66,7 +67,7 @@ const TicketActionButtons = ({ ticket }) => { loading={loading} startIcon={} size="small" - onClick={e => handleUpdateTicketStatus(e, "open", userId)} + onClick={e => handleUpdateTicketStatus(e, "open", user?.id)} > {i18n.t("messagesList.header.buttons.reopen")} @@ -86,7 +87,7 @@ const TicketActionButtons = ({ ticket }) => { size="small" variant="contained" color="primary" - onClick={e => handleUpdateTicketStatus(e, "closed", userId)} + onClick={e => handleUpdateTicketStatus(e, "closed", user?.id)} > {i18n.t("messagesList.header.buttons.resolve")} @@ -107,7 +108,7 @@ const TicketActionButtons = ({ ticket }) => { size="small" variant="contained" color="primary" - onClick={e => handleUpdateTicketStatus(e, "open", userId)} + onClick={e => handleUpdateTicketStatus(e, "open", user?.id)} > {i18n.t("messagesList.header.buttons.accept")} diff --git a/frontend/src/components/TicketListItem/index.js b/frontend/src/components/TicketListItem/index.js index 01c551e..254e8bb 100644 --- a/frontend/src/components/TicketListItem/index.js +++ b/frontend/src/components/TicketListItem/index.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useContext } from "react"; import { useHistory, useParams } from "react-router-dom"; import { parseISO, format, isSameDay } from "date-fns"; @@ -19,6 +19,8 @@ import { i18n } from "../../translate/i18n"; import api from "../../services/api"; import ButtonWithSpinner from "../ButtonWithSpinner"; import MarkdownWrapper from "../MarkdownWrapper"; +import { Tooltip } from "@material-ui/core"; +import { AuthContext } from "../../context/Auth/AuthContext"; const useStyles = makeStyles(theme => ({ ticket: { @@ -87,15 +89,24 @@ const useStyles = makeStyles(theme => ({ position: "absolute", left: "50%", }, + + ticketQueueColor: { + flex: "none", + width: "8px", + height: "100%", + position: "absolute", + top: "0%", + left: "0%", + }, })); const TicketListItem = ({ ticket }) => { const classes = useStyles(); const history = useHistory(); - const userId = +localStorage.getItem("userId"); const [loading, setLoading] = useState(false); const { ticketId } = useParams(); const isMounted = useRef(true); + const { user } = useContext(AuthContext); useEffect(() => { return () => { @@ -108,7 +119,7 @@ const TicketListItem = ({ ticket }) => { try { await api.put(`/tickets/${ticketId}`, { status: "open", - userId: userId, + userId: user?.id, }); } catch (err) { setLoading(false); @@ -138,6 +149,16 @@ const TicketListItem = ({ ticket }) => { [classes.pendingTicket]: ticket.status === "pending", })} > + + + { const [confirmationOpen, setConfirmationOpen] = useState(false); const [transferTicketModalOpen, setTransferTicketModalOpen] = useState(false); const isMounted = useRef(true); + const { user } = useContext(AuthContext); useEffect(() => { return () => { @@ -62,9 +65,16 @@ const TicketOptionsMenu = ({ ticket, menuOpen, handleClose, anchorEl }) => { open={menuOpen} onClose={handleClose} > - - {i18n.t("ticketOptionsMenu.delete")} - + ( + + {i18n.t("ticketOptionsMenu.delete")} + + )} + /> + {i18n.t("ticketOptionsMenu.transfer")} @@ -76,7 +86,7 @@ const TicketOptionsMenu = ({ ticket, menuOpen, handleClose, anchorEl }) => { ticket.contact.name }?`} open={confirmationOpen} - setOpen={setConfirmationOpen} + onClose={setConfirmationOpen} onConfirm={handleDeleteTicket} > {i18n.t("ticketOptionsMenu.confirmationModal.message")} diff --git a/frontend/src/components/TicketsList/index.js b/frontend/src/components/TicketsList/index.js index 458081d..f771154 100644 --- a/frontend/src/components/TicketsList/index.js +++ b/frontend/src/components/TicketsList/index.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useReducer } from "react"; +import React, { useState, useEffect, useReducer, useContext } from "react"; import openSocket from "socket.io-client"; import { makeStyles } from "@material-ui/core/styles"; @@ -7,9 +7,11 @@ import Paper from "@material-ui/core/Paper"; import TicketListItem from "../TicketListItem"; import TicketsListSkeleton from "../TicketsListSkeleton"; + import useTickets from "../../hooks/useTickets"; import { i18n } from "../../translate/i18n"; import { ListSubheader } from "@material-ui/core"; +import { AuthContext } from "../../context/Auth/AuthContext"; const useStyles = makeStyles(theme => ({ ticketsListWrapper: { @@ -34,6 +36,9 @@ const useStyles = makeStyles(theme => ({ zIndex: 2, backgroundColor: "white", borderBottom: "1px solid rgba(0, 0, 0, 0.12)", + display: "flex", + alignItems: "center", + justifyContent: "space-between", }, ticketsCount: { @@ -110,14 +115,14 @@ const reducer = (state, action) => { return [...state]; } - if (action.type === "UPDATE_TICKET_MESSAGES_COUNT") { - const { ticket, searchParam } = action.payload; + if (action.type === "UPDATE_TICKET_UNREAD_MESSAGES") { + const ticket = action.payload; const ticketIndex = state.findIndex(t => t.id === ticket.id); if (ticketIndex !== -1) { state[ticketIndex] = ticket; state.unshift(state.splice(ticketIndex, 1)[0]); - } else if (!searchParam) { + } else { state.unshift(ticket); } @@ -148,38 +153,47 @@ const reducer = (state, action) => { } }; -const TicketsList = ({ status, searchParam, showAll }) => { - const userId = +localStorage.getItem("userId"); +const TicketsList = ({ status, searchParam, showAll, selectedQueueIds }) => { const classes = useStyles(); const [pageNumber, setPageNumber] = useState(1); const [ticketsList, dispatch] = useReducer(reducer, []); + const { user } = useContext(AuthContext); useEffect(() => { dispatch({ type: "RESET" }); setPageNumber(1); - }, [status, searchParam, dispatch, showAll]); + }, [status, searchParam, dispatch, showAll, selectedQueueIds]); const { tickets, hasMore, loading } = useTickets({ pageNumber, searchParam, status, showAll, + queueIds: JSON.stringify(selectedQueueIds), }); useEffect(() => { + if (!status && !searchParam) return; dispatch({ type: "LOAD_TICKETS", payload: tickets, }); - }, [tickets]); + }, [tickets, status, searchParam]); useEffect(() => { const socket = openSocket(process.env.REACT_APP_BACKEND_URL); - if (status) { - socket.emit("joinTickets", status); - } else { - socket.emit("joinNotification"); - } + + const shouldUpdateTicket = ticket => + (!ticket.userId || ticket.userId === user?.id || showAll) && + (!ticket.queueId || selectedQueueIds.indexOf(ticket.queueId) > -1); + + socket.on("connect", () => { + if (status) { + socket.emit("joinTickets", status); + } else { + socket.emit("joinNotification"); + } + }); socket.on("ticket", data => { if (data.action === "updateUnread") { @@ -189,10 +203,7 @@ const TicketsList = ({ status, searchParam, showAll }) => { }); } - if ( - (data.action === "updateStatus" || data.action === "create") && - (!data.ticket.userId || data.ticket.userId === userId || showAll) - ) { + if (data.action === "update" && shouldUpdateTicket(data.ticket)) { dispatch({ type: "UPDATE_TICKET", payload: data.ticket, @@ -205,16 +216,10 @@ const TicketsList = ({ status, searchParam, showAll }) => { }); socket.on("appMessage", data => { - if ( - data.action === "create" && - (!data.ticket.userId || data.ticket.userId === userId || showAll) - ) { + if (data.action === "create" && shouldUpdateTicket(data.ticket)) { dispatch({ - type: "UPDATE_TICKET_MESSAGES_COUNT", - payload: { - ticket: data.ticket, - searchParam, - }, + type: "UPDATE_TICKET_UNREAD_MESSAGES", + payload: data.ticket, }); } }); @@ -231,7 +236,7 @@ const TicketsList = ({ status, searchParam, showAll }) => { return () => { socket.disconnect(); }; - }, [status, showAll, userId, searchParam]); + }, [status, showAll, user, selectedQueueIds]); const loadMore = () => { setPageNumber(prevState => prevState + 1); @@ -259,14 +264,22 @@ const TicketsList = ({ status, searchParam, showAll }) => { {status === "open" && ( - {i18n.t("ticketsList.assignedHeader")} - {ticketsList.length} +
+ {i18n.t("ticketsList.assignedHeader")} + + {ticketsList.length} + +
)} {status === "pending" && ( - {i18n.t("ticketsList.pendingHeader")} - {ticketsList.length} +
+ {i18n.t("ticketsList.pendingHeader")} + + {ticketsList.length} + +
)} {ticketsList.length === 0 && !loading ? ( diff --git a/frontend/src/components/TicketsManager/index.js b/frontend/src/components/TicketsManager/index.js index f905f36..691c906 100644 --- a/frontend/src/components/TicketsManager/index.js +++ b/frontend/src/components/TicketsManager/index.js @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useContext, useEffect, useRef, useState } from "react"; import { makeStyles } from "@material-ui/core/styles"; import Paper from "@material-ui/core/Paper"; @@ -8,8 +8,7 @@ import Tabs from "@material-ui/core/Tabs"; import Tab from "@material-ui/core/Tab"; import MoveToInboxIcon from "@material-ui/icons/MoveToInbox"; import CheckBoxIcon from "@material-ui/icons/CheckBox"; -import IconButton from "@material-ui/core/IconButton"; -import AddIcon from "@material-ui/icons/Add"; + import FormControlLabel from "@material-ui/core/FormControlLabel"; import Switch from "@material-ui/core/Switch"; @@ -18,6 +17,11 @@ import TicketsList from "../TicketsList"; import TabPanel from "../TabPanel"; import { i18n } from "../../translate/i18n"; +import { AuthContext } from "../../context/Auth/AuthContext"; +import { Can } from "../Can"; +import TicketsQueueSelect from "../TicketsQueueSelect"; +import { Button } from "@material-ui/core"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; const useStyles = makeStyles(theme => ({ ticketsWrapper: { @@ -46,17 +50,12 @@ const useStyles = makeStyles(theme => ({ width: 120, }, - ticketsListActions: { - flex: "none", - marginLeft: "auto", - }, - - searchBox: { - position: "relative", + ticketOptionsBox: { display: "flex", + justifyContent: "space-between", alignItems: "center", background: "#fafafa", - padding: "10px 13px", + padding: theme.spacing(1), }, serachInputWrapper: { @@ -65,6 +64,7 @@ const useStyles = makeStyles(theme => ({ display: "flex", borderRadius: 40, padding: 4, + marginRight: theme.spacing(1), }, searchIcon: { @@ -88,15 +88,35 @@ const TicketsManager = () => { const [tab, setTab] = useState("open"); const [newTicketModalOpen, setNewTicketModalOpen] = useState(false); const [showAllTickets, setShowAllTickets] = useState(false); + const { user } = useContext(AuthContext); + const searchInputRef = useRef(); + const [selectedQueueIds, setSelectedQueueIds] = useLocalStorage( + "selectedQueueIds", + [] + ); - const handleSearchContact = e => { - if (e.target.value === "") { - setSearchParam(e.target.value.toLowerCase()); + useEffect(() => { + if (tab === "search") { + searchInputRef.current.focus(); + } + }, [tab]); + + let searchTimeout; + + const handleSearch = e => { + const searchedTerm = e.target.value.toLowerCase(); + + clearTimeout(searchTimeout); + + if (searchedTerm === "") { + setSearchParam(searchedTerm); setTab("open"); return; } - setSearchParam(e.target.value.toLowerCase()); - setTab("search"); + + searchTimeout = setTimeout(() => { + setSearchParam(searchedTerm); + }, 500); }; const handleChangeTab = (e, newValue) => { @@ -138,50 +158,78 @@ const TicketsManager = () => { /> - -
- - -
-
- setShowAllTickets(prevState => !prevState)} - name="showAllTickets" - color="primary" - /> - } - /> - setNewTicketModalOpen(true)} - style={{ marginLeft: 20 }} - > - - -
+ + {tab === "search" ? ( +
+ + +
+ ) : ( + <> + + ( + + setShowAllTickets(prevState => !prevState) + } + name="showAllTickets" + color="primary" + /> + } + /> + )} + /> + + )} + setSelectedQueueIds(values)} + />
- - + + - + - +
); diff --git a/frontend/src/components/TicketsQueueSelect/index.js b/frontend/src/components/TicketsQueueSelect/index.js new file mode 100644 index 0000000..a774eb2 --- /dev/null +++ b/frontend/src/components/TicketsQueueSelect/index.js @@ -0,0 +1,59 @@ +import React from "react"; + +import MenuItem from "@material-ui/core/MenuItem"; +import FormControl from "@material-ui/core/FormControl"; +import Select from "@material-ui/core/Select"; +import { Checkbox, ListItemText } from "@material-ui/core"; + +const TicketsQueueSelect = ({ + userQueues, + selectedQueueIds = [], + onChange, +}) => { + const handleChange = e => { + onChange(e.target.value); + }; + + return ( +
+ + + +
+ ); +}; + +export default TicketsQueueSelect; diff --git a/frontend/src/components/UserModal/index.js b/frontend/src/components/UserModal/index.js index dffdd6e..9518823 100644 --- a/frontend/src/components/UserModal/index.js +++ b/frontend/src/components/UserModal/index.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useContext } from "react"; import * as Yup from "yup"; import { Formik, Form, Field } from "formik"; @@ -22,15 +22,20 @@ import { i18n } from "../../translate/i18n"; import api from "../../services/api"; import toastError from "../../errors/toastError"; +import QueueSelect from "../QueueSelect"; +import { AuthContext } from "../../context/Auth/AuthContext"; +import { Can } from "../Can"; const useStyles = makeStyles(theme => ({ root: { display: "flex", flexWrap: "wrap", }, - textField: { - marginRight: theme.spacing(1), - flex: 1, + multFieldLine: { + display: "flex", + "& > *:not(:last-child)": { + marginRight: theme.spacing(1), + }, }, btnWrapper: { @@ -70,7 +75,10 @@ const UserModal = ({ open, onClose, userId }) => { profile: "user", }; + const { user: loggedInUser } = useContext(AuthContext); + const [user, setUser] = useState(initialState); + const [selectedQueueIds, setSelectedQueueIds] = useState([]); useEffect(() => { const fetchUser = async () => { @@ -80,6 +88,8 @@ const UserModal = ({ open, onClose, userId }) => { setUser(prevState => { return { ...prevState, ...data }; }); + const userQueueIds = data.queues?.map(queue => queue.id); + setSelectedQueueIds(userQueueIds); } catch (err) { toastError(err); } @@ -94,11 +104,12 @@ const UserModal = ({ open, onClose, userId }) => { }; const handleSaveUser = async values => { + const userData = { ...values, queueIds: selectedQueueIds }; try { if (userId) { - await api.put(`/users/${userId}`, values); + await api.put(`/users/${userId}`, userData); } else { - await api.post("/users", values); + await api.post("/users", userData); } toast.success(i18n.t("userModal.success")); } catch (err) { @@ -109,7 +120,13 @@ const UserModal = ({ open, onClose, userId }) => { return (
- + {userId ? `${i18n.t("userModal.title.edit")}` @@ -129,27 +146,18 @@ const UserModal = ({ open, onClose, userId }) => { {({ touched, errors, isSubmitting }) => (
- - -
+
+ { helperText={touched.password && errors.password} variant="outlined" margin="dense" + fullWidth + /> +
+
+ - - {i18n.t("userModal.form.profile")} - - - Admin - User - + ( + <> + + {i18n.t("userModal.form.profile")} + + + + Admin + User + + + )} + />
+ ( + setSelectedQueueIds(values)} + /> + )} + /> - - - - )} - -
+
+
+ +
+ setSelectedQueueIds(values)} + /> + + + + + + + )} + + + ); }; diff --git a/frontend/src/context/Auth/AuthContext.js b/frontend/src/context/Auth/AuthContext.js index aebe887..87bd9ae 100644 --- a/frontend/src/context/Auth/AuthContext.js +++ b/frontend/src/context/Auth/AuthContext.js @@ -5,11 +5,11 @@ import useAuth from "./useAuth"; const AuthContext = createContext(); const AuthProvider = ({ children }) => { - const { isAuth, loading, handleLogin, handleLogout } = useAuth(); + const { loading, user, isAuth, handleLogin, handleLogout } = useAuth(); return ( {children} diff --git a/frontend/src/context/Auth/useAuth.js b/frontend/src/context/Auth/useAuth.js index b081bc0..30b79f9 100644 --- a/frontend/src/context/Auth/useAuth.js +++ b/frontend/src/context/Auth/useAuth.js @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { useHistory } from "react-router-dom"; +import openSocket from "socket.io-client"; import { toast } from "react-toastify"; @@ -11,6 +12,7 @@ const useAuth = () => { const history = useHistory(); const [isAuth, setIsAuth] = useState(false); const [loading, setLoading] = useState(true); + const [user, setUser] = useState({}); api.interceptors.request.use( config => { @@ -44,9 +46,6 @@ const useAuth = () => { } if (error?.response?.status === 401) { localStorage.removeItem("token"); - localStorage.removeItem("username"); - localStorage.removeItem("profile"); - localStorage.removeItem("userId"); api.defaults.headers.Authorization = undefined; setIsAuth(false); } @@ -56,49 +55,64 @@ const useAuth = () => { useEffect(() => { const token = localStorage.getItem("token"); - if (token) { - api.defaults.headers.Authorization = `Bearer ${JSON.parse(token)}`; - setIsAuth(true); - } - setLoading(false); + (async () => { + if (token) { + try { + const { data } = await api.post("/auth/refresh_token"); + api.defaults.headers.Authorization = `Bearer ${data.token}`; + setIsAuth(true); + setUser(data.user); + } catch (err) { + toastError(err); + } + } + setLoading(false); + })(); }, []); - const handleLogin = async (e, user) => { + useEffect(() => { + const socket = openSocket(process.env.REACT_APP_BACKEND_URL); + + socket.on("user", data => { + if (data.action === "update" && data.user.id === user.id) { + setUser(data.user); + } + }); + + return () => { + socket.disconnect(); + }; + }, [user]); + + const handleLogin = async user => { setLoading(true); - e.preventDefault(); try { const { data } = await api.post("/auth/login", user); localStorage.setItem("token", JSON.stringify(data.token)); - localStorage.setItem("username", data.username); - localStorage.setItem("profile", data.profile); - localStorage.setItem("userId", data.userId); api.defaults.headers.Authorization = `Bearer ${data.token}`; - + setUser(data.user); setIsAuth(true); toast.success(i18n.t("auth.toasts.success")); history.push("/tickets"); + setLoading(false); } catch (err) { toastError(err); + setLoading(false); } - - setLoading(false); }; - const handleLogout = e => { + const handleLogout = () => { setLoading(true); - e.preventDefault(); setIsAuth(false); + setUser({}); localStorage.removeItem("token"); - localStorage.removeItem("username"); - localStorage.removeItem("profile"); - localStorage.removeItem("userId"); api.defaults.headers.Authorization = undefined; setLoading(false); history.push("/login"); }; - return { isAuth, setIsAuth, loading, handleLogin, handleLogout }; + return { isAuth, user, loading, handleLogin, handleLogout }; }; export default useAuth; diff --git a/frontend/src/hooks/useLocalStorage/index.js b/frontend/src/hooks/useLocalStorage/index.js new file mode 100644 index 0000000..3a6ee35 --- /dev/null +++ b/frontend/src/hooks/useLocalStorage/index.js @@ -0,0 +1,29 @@ +import { useState } from "react"; +import toastError from "../../errors/toastError"; + +export function useLocalStorage(key, initialValue) { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + toastError(error); + return initialValue; + } + }); + + const setValue = value => { + try { + const valueToStore = + value instanceof Function ? value(storedValue) : value; + + setStoredValue(valueToStore); + + localStorage.setItem(key, JSON.stringify(valueToStore)); + } catch (error) { + toastError(error); + } + }; + + return [storedValue, setValue]; +} diff --git a/frontend/src/hooks/useTickets/index.js b/frontend/src/hooks/useTickets/index.js index e6eeed3..97b6e7f 100644 --- a/frontend/src/hooks/useTickets/index.js +++ b/frontend/src/hooks/useTickets/index.js @@ -9,6 +9,7 @@ const useTickets = ({ status, date, showAll, + queueIds, withUnreadMessages, }) => { const [loading, setLoading] = useState(true); @@ -27,6 +28,7 @@ const useTickets = ({ status, date, showAll, + queueIds, withUnreadMessages, }, }); @@ -41,7 +43,15 @@ const useTickets = ({ fetchTickets(); }, 500); return () => clearTimeout(delayDebounceFn); - }, [searchParam, pageNumber, status, date, showAll, withUnreadMessages]); + }, [ + searchParam, + pageNumber, + status, + date, + showAll, + queueIds, + withUnreadMessages, + ]); return { tickets, loading, hasMore }; }; diff --git a/frontend/src/components/_layout/MainListItems.js b/frontend/src/layout/MainListItems.js similarity index 59% rename from frontend/src/components/_layout/MainListItems.js rename to frontend/src/layout/MainListItems.js index 2b1e4b4..e8e68b2 100644 --- a/frontend/src/components/_layout/MainListItems.js +++ b/frontend/src/layout/MainListItems.js @@ -6,18 +6,19 @@ import ListItemIcon from "@material-ui/core/ListItemIcon"; import ListItemText from "@material-ui/core/ListItemText"; import ListSubheader from "@material-ui/core/ListSubheader"; import Divider from "@material-ui/core/Divider"; -import DashboardIcon from "@material-ui/icons/Dashboard"; +import { Badge } from "@material-ui/core"; +import DashboardOutlinedIcon from "@material-ui/icons/DashboardOutlined"; import WhatsAppIcon from "@material-ui/icons/WhatsApp"; import SyncAltIcon from "@material-ui/icons/SyncAlt"; -import SettingsIcon from "@material-ui/icons/Settings"; -import GroupIcon from "@material-ui/icons/Group"; +import SettingsOutlinedIcon from "@material-ui/icons/SettingsOutlined"; +import PeopleAltOutlinedIcon from "@material-ui/icons/PeopleAltOutlined"; +import ContactPhoneOutlinedIcon from "@material-ui/icons/ContactPhoneOutlined"; +import AccountTreeOutlinedIcon from "@material-ui/icons/AccountTreeOutlined"; -import ContactPhoneIcon from "@material-ui/icons/ContactPhone"; - -import { i18n } from "../../translate/i18n"; -import { Badge } from "@material-ui/core"; - -import { WhatsAppsContext } from "../../context/WhatsApp/WhatsAppsContext"; +import { i18n } from "../translate/i18n"; +import { WhatsAppsContext } from "../context/WhatsApp/WhatsAppsContext"; +import { AuthContext } from "../context/Auth/AuthContext"; +import { Can } from "../components/Can"; function ListItemLink(props) { const { icon, primary, to, className } = props; @@ -41,8 +42,8 @@ function ListItemLink(props) { } const MainListItems = () => { - const userProfile = localStorage.getItem("profile"); const { whatsApps } = useContext(WhatsAppsContext); + const { user } = useContext(AuthContext); const [connectionWarning, setConnectionWarning] = useState(false); useEffect(() => { @@ -71,7 +72,11 @@ const MainListItems = () => { return (
- } /> + } + /> { } + icon={} + /> + ( + <> + + + {i18n.t("mainDrawer.listItems.administration")} + + } + /> + } + /> + } + /> + + )} /> - {userProfile === "admin" && ( - <> - - - {i18n.t("mainDrawer.listItems.administration")} - - } - /> - } - /> - - )}
); }; diff --git a/frontend/src/components/_layout/index.js b/frontend/src/layout/index.js similarity index 78% rename from frontend/src/components/_layout/index.js rename to frontend/src/layout/index.js index bff2c59..9cdc97f 100644 --- a/frontend/src/components/_layout/index.js +++ b/frontend/src/layout/index.js @@ -1,4 +1,4 @@ -import React, { useState, useContext, useEffect } from "react"; +import React, { useState, useContext } from "react"; import clsx from "clsx"; import { @@ -19,11 +19,12 @@ import ChevronLeftIcon from "@material-ui/icons/ChevronLeft"; import AccountCircle from "@material-ui/icons/AccountCircle"; import MainListItems from "./MainListItems"; -import NotificationsPopOver from "../NotificationsPopOver"; -import UserModal from "../UserModal"; -import { AuthContext } from "../../context/Auth/AuthContext"; -import BackdropLoading from "../BackdropLoading"; -import { i18n } from "../../translate/i18n"; +import NotificationsPopOver from "../components/NotificationsPopOver"; +import UserModal from "../components/UserModal"; +import { AuthContext } from "../context/Auth/AuthContext"; +import BackdropLoading from "../components/BackdropLoading"; +import { i18n } from "../translate/i18n"; +import { useLocalStorage } from "../hooks/useLocalStorage"; const drawerWidth = 240; @@ -107,30 +108,13 @@ const useStyles = makeStyles(theme => ({ })); const LoggedInLayout = ({ children }) => { - const drawerState = localStorage.getItem("drawerOpen"); - const userId = +localStorage.getItem("userId"); const classes = useStyles(); - const [open, setOpen] = useState(true); const [userModalOpen, setUserModalOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); const [menuOpen, setMenuOpen] = useState(false); const { handleLogout, loading } = useContext(AuthContext); - - useEffect(() => { - if (drawerState === "0") { - setOpen(false); - } - }, [drawerState]); - - const handleDrawerOpen = () => { - setOpen(true); - localStorage.setItem("drawerOpen", 1); - }; - - const handleDrawerClose = () => { - setOpen(false); - localStorage.setItem("drawerOpen", 0); - }; + const [drawerOpen, setDrawerOpen] = useLocalStorage("drawerOpen", true); + const { user } = useContext(AuthContext); const handleMenu = event => { setAnchorEl(event.currentTarget); @@ -156,12 +140,15 @@ const LoggedInLayout = ({ children }) => {
- + setDrawerOpen(!drawerOpen)}>
@@ -174,11 +161,11 @@ const LoggedInLayout = ({ children }) => { setUserModalOpen(false)} - userId={userId} + userId={user?.id} /> @@ -186,10 +173,10 @@ const LoggedInLayout = ({ children }) => { edge="start" color="inherit" aria-label="open drawer" - onClick={handleDrawerOpen} + onClick={() => setDrawerOpen(!drawerOpen)} className={clsx( classes.menuButton, - open && classes.menuButtonHidden + drawerOpen && classes.menuButtonHidden )} > diff --git a/frontend/src/pages/Connections/index.js b/frontend/src/pages/Connections/index.js index f1f5fb0..8974ce2 100644 --- a/frontend/src/pages/Connections/index.js +++ b/frontend/src/pages/Connections/index.js @@ -294,7 +294,7 @@ const Connections = () => { {confirmModalInfo.message} diff --git a/frontend/src/pages/Contacts/index.js b/frontend/src/pages/Contacts/index.js index 6bb0f24..d4204d7 100644 --- a/frontend/src/pages/Contacts/index.js +++ b/frontend/src/pages/Contacts/index.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useReducer } from "react"; +import React, { useState, useEffect, useReducer, useContext } from "react"; import openSocket from "socket.io-client"; import { toast } from "react-toastify"; import { useHistory } from "react-router-dom"; @@ -32,6 +32,8 @@ import Title from "../../components/Title"; import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper"; import MainContainer from "../../components/MainContainer"; import toastError from "../../errors/toastError"; +import { AuthContext } from "../../context/Auth/AuthContext"; +import { Can } from "../../components/Can"; const reducer = (state, action) => { if (action.type === "LOAD_CONTACTS") { @@ -89,7 +91,8 @@ const useStyles = makeStyles(theme => ({ const Contacts = () => { const classes = useStyles(); const history = useHistory(); - const userId = +localStorage.getItem("userId"); + + const { user } = useContext(AuthContext); const [loading, setLoading] = useState(false); const [pageNumber, setPageNumber] = useState(1); @@ -128,6 +131,7 @@ const Contacts = () => { useEffect(() => { const socket = openSocket(process.env.REACT_APP_BACKEND_URL); + socket.on("contact", data => { if (data.action === "update" || data.action === "create") { dispatch({ type: "UPDATE_CONTACTS", payload: data.contact }); @@ -163,7 +167,7 @@ const Contacts = () => { try { const { data: ticket } = await api.post("/tickets", { contactId: contactId, - userId: userId, + userId: user?.id, status: "open", }); history.push(`/tickets/${ticket.id}`); @@ -228,7 +232,7 @@ const Contacts = () => { : `${i18n.t("contacts.confirmationModal.importTitlte")}` } open={confirmOpen} - setOpen={setConfirmOpen} + onClose={setConfirmOpen} onConfirm={e => deletingContact ? handleDeleteContact(deletingContact.id) @@ -315,15 +319,21 @@ const Contacts = () => { > - { - setConfirmOpen(true); - setDeletingContact(contact); - }} - > - - + ( + { + setConfirmOpen(true); + setDeletingContact(contact); + }} + > + + + )} + /> ))} diff --git a/frontend/src/pages/Login/index.js b/frontend/src/pages/Login/index.js index 4600680..ebb98d6 100644 --- a/frontend/src/pages/Login/index.js +++ b/frontend/src/pages/Login/index.js @@ -61,6 +61,11 @@ const Login = () => { setUser({ ...user, [e.target.name]: e.target.value }); }; + const handlSubmit = e => { + e.preventDefault(); + handleLogin(user); + }; + return ( @@ -71,11 +76,7 @@ const Login = () => { {i18n.t("login.title")} -
handleLogin(e, user)} - > + ({ + mainPaper: { + flex: 1, + padding: theme.spacing(1), + overflowY: "scroll", + ...theme.scrollbarStyles, + }, + customTableCell: { + display: "flex", + + alignItems: "center", + justifyContent: "center", + }, +})); + +const reducer = (state, action) => { + if (action.type === "LOAD_QUEUES") { + const queues = action.payload; + const newQueues = []; + + queues.forEach(queue => { + const queueIndex = state.findIndex(q => q.id === queue.id); + if (queueIndex !== -1) { + state[queueIndex] = queue; + } else { + newQueues.push(queue); + } + }); + + return [...state, ...newQueues]; + } + + if (action.type === "UPDATE_QUEUES") { + const queue = action.payload; + const queueIndex = state.findIndex(u => u.id === queue.id); + + if (queueIndex !== -1) { + state[queueIndex] = queue; + return [...state]; + } else { + return [queue, ...state]; + } + } + + if (action.type === "DELETE_QUEUE") { + const queueId = action.payload; + const queueIndex = state.findIndex(q => q.id === queueId); + if (queueIndex !== -1) { + state.splice(queueIndex, 1); + } + return [...state]; + } + + if (action.type === "RESET") { + return []; + } +}; + +const Queues = () => { + const classes = useStyles(); + + const [queues, dispatch] = useReducer(reducer, []); + const [loading, setLoading] = useState(false); + + const [queueModalOpen, setQueueModalOpen] = useState(false); + const [selectedQueue, setSelectedQueue] = useState(null); + const [confirmModalOpen, setConfirmModalOpen] = useState(false); + + useEffect(() => { + (async () => { + setLoading(true); + try { + const { data } = await api.get("/queue"); + dispatch({ type: "LOAD_QUEUES", payload: data }); + + setLoading(false); + } catch (err) { + toastError(err); + setLoading(false); + } + })(); + }, []); + + useEffect(() => { + const socket = openSocket(process.env.REACT_APP_BACKEND_URL); + + socket.on("queue", data => { + if (data.action === "update" || data.action === "create") { + dispatch({ type: "UPDATE_QUEUES", payload: data.queue }); + } + + if (data.action === "delete") { + dispatch({ type: "DELETE_QUEUE", payload: data.queueId }); + } + }); + + return () => { + socket.disconnect(); + }; + }, []); + + const handleOpenQueueModal = () => { + setQueueModalOpen(true); + setSelectedQueue(null); + }; + + const handleCloseQueueModal = () => { + setQueueModalOpen(false); + setSelectedQueue(null); + }; + + const handleEditQueue = queue => { + setSelectedQueue(queue); + setQueueModalOpen(true); + }; + + const handleCloseConfirmationModal = () => { + setConfirmModalOpen(false); + setSelectedQueue(null); + }; + + const handleDeleteQueue = async queueId => { + try { + await api.delete(`/queue/${queueId}`); + toast.success(i18n.t("Queue deleted successfully!")); + } catch (err) { + toastError(err); + } + setSelectedQueue(null); + }; + + return ( + + handleDeleteQueue(selectedQueue.id)} + > + {i18n.t("queues.confirmationModal.deleteMessage")} + + + + {i18n.t("queues.title")} + + + + + + + + + + {i18n.t("queues.table.name")} + + + {i18n.t("queues.table.color")} + + + {i18n.t("queues.table.greeting")} + + + {i18n.t("queues.table.actions")} + + + + + <> + {queues.map(queue => ( + + {queue.name} + +
+ +
+
+ +
+ + {queue.greetingMessage} + +
+
+ + handleEditQueue(queue)} + > + + + + { + setSelectedQueue(queue); + setConfirmModalOpen(true); + }} + > + + + +
+ ))} + {loading && } + +
+
+
+
+ ); +}; + +export default Queues; diff --git a/frontend/src/pages/Settings/index.js b/frontend/src/pages/Settings/index.js index 4fd64ab..2fc4097 100644 --- a/frontend/src/pages/Settings/index.js +++ b/frontend/src/pages/Settings/index.js @@ -52,6 +52,7 @@ const Settings = () => { useEffect(() => { const socket = openSocket(process.env.REACT_APP_BACKEND_URL); + socket.on("settings", data => { if (data.action === "update") { setSettings(prevState => { diff --git a/frontend/src/pages/Users/index.js b/frontend/src/pages/Users/index.js index 7f4d440..a457b5d 100644 --- a/frontend/src/pages/Users/index.js +++ b/frontend/src/pages/Users/index.js @@ -124,6 +124,7 @@ const Users = () => { useEffect(() => { const socket = openSocket(process.env.REACT_APP_BACKEND_URL); + socket.on("user", data => { if (data.action === "update" || data.action === "create") { dispatch({ type: "UPDATE_USERS", payload: data.user }); @@ -192,7 +193,7 @@ const Users = () => { }?` } open={confirmModalOpen} - setOpen={setConfirmModalOpen} + onClose={setConfirmModalOpen} onConfirm={() => handleDeleteUser(deletingUser.id)} > {i18n.t("users.confirmationModal.deleteMessage")} diff --git a/frontend/src/routes/Route.js b/frontend/src/routes/Route.js index d0643a4..fe3e2b2 100644 --- a/frontend/src/routes/Route.js +++ b/frontend/src/routes/Route.js @@ -7,19 +7,30 @@ import BackdropLoading from "../components/BackdropLoading"; const RouteWrapper = ({ component: Component, isPrivate = false, ...rest }) => { const { isAuth, loading } = useContext(AuthContext); - if (loading) return ; - if (!isAuth && isPrivate) { return ( - + <> + {loading && } + + ); } if (isAuth && !isPrivate) { - return ; + return ( + <> + {loading && } + ; + + ); } - return ; + return ( + <> + {loading && } + + + ); }; export default RouteWrapper; diff --git a/frontend/src/routes/index.js b/frontend/src/routes/index.js index 852e0c9..46b6cad 100644 --- a/frontend/src/routes/index.js +++ b/frontend/src/routes/index.js @@ -2,7 +2,7 @@ import React from "react"; import { BrowserRouter, Switch } from "react-router-dom"; import { ToastContainer } from "react-toastify"; -import LoggedInLayout from "../components/_layout"; +import LoggedInLayout from "../layout"; import Dashboard from "../pages/Dashboard/"; import Tickets from "../pages/Tickets/"; import Signup from "../pages/Signup/"; @@ -11,6 +11,7 @@ import Connections from "../pages/Connections/"; import Settings from "../pages/Settings/"; import Users from "../pages/Users"; import Contacts from "../pages/Contacts/"; +import Queues from "../pages/Queues/"; import { AuthProvider } from "../context/Auth/AuthContext"; import { WhatsAppsProvider } from "../context/WhatsApp/WhatsAppsContext"; import Route from "./Route"; @@ -40,6 +41,7 @@ const Routes = () => { + diff --git a/frontend/src/translate/languages/en.js b/frontend/src/translate/languages/en.js index 0ab8b46..fe73f7d 100644 --- a/frontend/src/translate/languages/en.js +++ b/frontend/src/translate/languages/en.js @@ -153,6 +153,22 @@ const messages = { }, success: "Contact saved successfully.", }, + queueModal: { + title: { + add: "Add queue", + edit: "Edit queue", + }, + form: { + name: "Name", + color: "Color", + greetingMessage: "Greeting Message", + }, + buttons: { + okAdd: "Add", + okEdit: "Save", + cancel: "Cancel", + }, + }, userModal: { title: { add: "Add user", @@ -226,6 +242,7 @@ const messages = { connections: "Connections", tickets: "Tickets", contacts: "Contacts", + queues: "Queues", administration: "Administration", users: "Users", settings: "Settings", @@ -240,6 +257,23 @@ const messages = { notifications: { noTickets: "No notifications.", }, + queues: { + title: "Queues", + table: { + name: "Name", + color: "Color", + greeting: "Greeting message", + actions: "Actions", + }, + buttons: { + add: "Add queue", + }, + confirmationModal: { + deleteTitle: "Delete", + deleteMessage: + "Are you sure? It cannot be reverted! Tickets in this queue will still exist, but will not have any queues assigned.", + }, + }, users: { title: "Users", table: { diff --git a/frontend/src/translate/languages/es.js b/frontend/src/translate/languages/es.js index dfdd70a..48a6519 100644 --- a/frontend/src/translate/languages/es.js +++ b/frontend/src/translate/languages/es.js @@ -156,6 +156,22 @@ const messages = { }, success: "Contacto guardado satisfactoriamente.", }, + queueModal: { + title: { + add: "Agregar cola", + edit: "Editar cola", + }, + form: { + name: "Nombre", + color: "Color", + greetingMessage: "Mensaje de saludo", + }, + buttons: { + okAdd: "Añadir", + okEdit: "Ahorrar", + cancel: "Cancelar", + }, + }, userModal: { title: { add: "Agregar usuario", @@ -230,6 +246,7 @@ const messages = { connections: "Conexiones", tickets: "Tickets", contacts: "Contactos", + queues: "Linhas", administration: "Administración", users: "Usuarios", settings: "Configuración", @@ -244,6 +261,23 @@ const messages = { notifications: { noTickets: "Sin notificaciones.", }, + queues: { + title: "Linhas", + table: { + name: "Nombre", + color: "Color", + greeting: "Mensaje de saludo", + actions: "Comportamiento", + }, + buttons: { + add: "Agregar cola", + }, + confirmationModal: { + deleteTitle: "Eliminar", + deleteMessage: + "¿Estás seguro? ¡Esta acción no se puede revertir! Los tickets en esa cola seguirán existiendo, pero ya no tendrán ninguna cola asignada.", + }, + }, users: { title: "Usuarios", table: { diff --git a/frontend/src/translate/languages/pt.js b/frontend/src/translate/languages/pt.js index f98eddc..4b04b8c 100644 --- a/frontend/src/translate/languages/pt.js +++ b/frontend/src/translate/languages/pt.js @@ -154,6 +154,22 @@ const messages = { }, success: "Contato salvo com sucesso.", }, + queueModal: { + title: { + add: "Adicionar fila", + edit: "Editar fila", + }, + form: { + name: "Nome", + color: "Cor", + greetingMessage: "Mensagem de saudação", + }, + buttons: { + okAdd: "Adicionar", + okEdit: "Salvar", + cancel: "Cancelar", + }, + }, userModal: { title: { add: "Adicionar usuário", @@ -228,6 +244,7 @@ const messages = { connections: "Conexões", tickets: "Tickets", contacts: "Contatos", + queues: "Filas", administration: "Administração", users: "Usuários", settings: "Configurações", @@ -242,6 +259,23 @@ const messages = { notifications: { noTickets: "Nenhuma notificação.", }, + queues: { + title: "Filas", + table: { + name: "Nome", + color: "Cor", + greeting: "Mensagem de saudação", + actions: "Ações", + }, + buttons: { + add: "Adicionar fila", + }, + confirmationModal: { + deleteTitle: "Excluir", + deleteMessage: + "Você tem certeza? Essa ação não pode ser revertida! Os tickets dessa fila continuarão existindo, mas não terão mais nenhuma fila atribuída.", + }, + }, users: { title: "Usuários", table: {