From 5af995c54e7ff0e38d958538478a4aac5b50758b Mon Sep 17 00:00:00 2001 From: DaviMBDev Date: Wed, 29 Apr 2026 23:33:05 -0300 Subject: [PATCH 1/2] feat/escalona tickets --- .../application/chat.file-message.spec.ts | 5 +++ .../chat/application/chat.service.spec.ts | 5 +++ .../modules/chat/application/chat.service.ts | 40 ++++++++++++++++++- backend/src/modules/chat/chat.module.ts | 4 ++ .../modules/chat/domain/chat.repository.ts | 2 + .../chat/infra/chat.repository.mongodb.ts | 8 ++++ .../useCases/create/create.usecase.spec.ts | 8 +++- .../useCases/delete/delete.usecase.spec.ts | 8 +++- .../escalate/escalate.usecase.spec.ts | 11 ++++- .../useCases/escalate/escalate.usecase.ts | 12 +++++- .../newAgent/newAgent.usecase.spec.ts | 8 +++- .../useCases/newAgent/newAgent.usecase.ts | 13 +++++- .../domain/entities/tickect.entity.spec.ts | 3 +- .../ticket/domain/entities/ticket.entity.ts | 24 +++++++---- .../controllers/ticket.controller.spec.ts | 12 +++--- backend/src/modules/ticket/ticket.module.ts | 2 + 16 files changed, 141 insertions(+), 24 deletions(-) diff --git a/backend/src/modules/chat/application/chat.file-message.spec.ts b/backend/src/modules/chat/application/chat.file-message.spec.ts index e52d451..45576d9 100644 --- a/backend/src/modules/chat/application/chat.file-message.spec.ts +++ b/backend/src/modules/chat/application/chat.file-message.spec.ts @@ -1,5 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getModelToken } from '@nestjs/mongoose'; import { ChatService } from './chat.service'; +import { TicketSchemaClass } from '../../ticket/infra/schemas/ticket.mongo.schema'; import { UserRole } from '../../user/user.schema'; import { NotFoundException } from '@nestjs/common'; @@ -12,6 +14,7 @@ const mockChatRepository: jest.Mocked = { findByParticipant: jest.fn(), findByTicketId: jest.fn(), updateStatus: jest.fn(), + updateAgent: jest.fn(), }; const mockMessageRepository: jest.Mocked = { @@ -41,6 +44,8 @@ describe('ChatService — envio de mensagem com arquivo', () => { ChatService, { provide: 'IChatRepository', useValue: mockChatRepository }, { provide: 'IMessageRepository', useValue: mockMessageRepository }, + { provide: getModelToken(TicketSchemaClass.name), useValue: {} }, + { provide: getModelToken(UserRole.ADMIN ? 'User' : 'User'), useValue: {} }, ], }).compile(); diff --git a/backend/src/modules/chat/application/chat.service.spec.ts b/backend/src/modules/chat/application/chat.service.spec.ts index 1a71ac2..92bca6c 100644 --- a/backend/src/modules/chat/application/chat.service.spec.ts +++ b/backend/src/modules/chat/application/chat.service.spec.ts @@ -1,5 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getModelToken } from '@nestjs/mongoose'; import { ChatService } from './chat.service'; +import { TicketSchemaClass } from '../../ticket/infra/schemas/ticket.mongo.schema'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { ChatDetails, ChatStatus } from '../domain/chat.entity'; import { IChatRepository } from '../domain/chat.repository'; @@ -12,6 +14,7 @@ const mockChatRepository: jest.Mocked = { findByParticipant: jest.fn(), findByTicketId: jest.fn(), updateStatus: jest.fn(), + updateAgent: jest.fn(), }; const mockMessageRepository: jest.Mocked = { @@ -45,6 +48,8 @@ describe('ChatService', () => { ChatService, { provide: 'IChatRepository', useValue: mockChatRepository }, { provide: 'IMessageRepository', useValue: mockMessageRepository }, + { provide: getModelToken(TicketSchemaClass.name), useValue: {} }, + { provide: getModelToken(UserRole.ADMIN ? 'User' : 'User'), useValue: {} }, // Gambiarra provisória pra obter a string 'User' ], }).compile(); diff --git a/backend/src/modules/chat/application/chat.service.ts b/backend/src/modules/chat/application/chat.service.ts index 9d2c591..9750525 100644 --- a/backend/src/modules/chat/application/chat.service.ts +++ b/backend/src/modules/chat/application/chat.service.ts @@ -8,7 +8,10 @@ import type { IChatRepository } from '../domain/chat.repository'; import type { IMessageRepository } from '../../Messages/domain/message.repository'; import type { ChatDetails } from '../domain/chat.entity'; import { ChatStatus } from '../domain/chat.entity'; -import { UserRole } from '../../user/user.schema'; +import { User, UserDocument, UserRole } from '../../user/user.schema'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { TicketSchemaClass, TicketDocument } from '../../ticket/infra/schemas/ticket.mongo.schema'; @Injectable() export class ChatService { @@ -17,6 +20,8 @@ export class ChatService { private readonly chatRepository: IChatRepository, @Inject('IMessageRepository') private readonly messageRepository: IMessageRepository, + @InjectModel(TicketSchemaClass.name) private ticketModel: Model, + @InjectModel(User.name) private userModel: Model, ) {} async createChat( @@ -105,11 +110,42 @@ export class ChatService { return result; } + async updateAgentByTicketId(ticketId: string, agentId: string | null): Promise { + return this.chatRepository.updateAgent(ticketId, agentId); + } + async isParticipant(chatId: string, userId: string): Promise { const chat = await this.chatRepository.findById(chatId); if (!chat) { return false; } - return chat.clientId === userId || chat.agentId === userId; + + if (chat.clientId === userId || chat.agentId === userId) { + return true; + } + + // Se o chat está na "Fila Aberta" (Sem dono) + if (!chat.agentId) { + const user = await this.userModel.findById(userId).exec(); + const ticket = await this.ticketModel.findById(chat.ticketId).exec(); + + if (user && ticket) { + if (user.role === UserRole.ADMIN) { + return true; + } + + if (user.role === UserRole.SUPPORT) { + const hasCategory = user.categories.some( + (cat) => cat.toString() === ticket.category.toString() + ); + + if (hasCategory && (user.level || 1) >= ticket.escalationLevel) { + return true; + } + } + } + } + + return false; } } \ No newline at end of file diff --git a/backend/src/modules/chat/chat.module.ts b/backend/src/modules/chat/chat.module.ts index 150114c..88ee0b2 100644 --- a/backend/src/modules/chat/chat.module.ts +++ b/backend/src/modules/chat/chat.module.ts @@ -8,12 +8,16 @@ import { ChatGateway } from './presentation/chat.gateway'; import { Message, MessageSchema } from '../Messages/infra/message.schema'; import { MessageRepositoryMongodb } from '../Messages/infra/message.repository.mongodb'; import { ChatController } from './presentation/chat.controller'; +import { TicketSchemaClass, TicketSchema } from '../ticket/infra/schemas/ticket.mongo.schema'; +import { User, UserSchema } from '../user/user.schema'; @Module({ imports: [ MongooseModule.forFeature([ { name: Chat.name, schema: ChatSchema }, { name: Message.name, schema: MessageSchema }, + { name: TicketSchemaClass.name, schema: TicketSchema }, + { name: User.name, schema: UserSchema }, ]), JwtModule.register({ secret: 'secret' }), ], diff --git a/backend/src/modules/chat/domain/chat.repository.ts b/backend/src/modules/chat/domain/chat.repository.ts index 57e03fd..b1113ed 100644 --- a/backend/src/modules/chat/domain/chat.repository.ts +++ b/backend/src/modules/chat/domain/chat.repository.ts @@ -15,4 +15,6 @@ export interface IChatRepository { findByTicketId(ticketId: string): Promise; updateStatus(id: string, status: ChatStatus): Promise; + + updateAgent(ticketId: string, agentId: string | null): Promise; } diff --git a/backend/src/modules/chat/infra/chat.repository.mongodb.ts b/backend/src/modules/chat/infra/chat.repository.mongodb.ts index 19541b3..840adae 100644 --- a/backend/src/modules/chat/infra/chat.repository.mongodb.ts +++ b/backend/src/modules/chat/infra/chat.repository.mongodb.ts @@ -87,4 +87,12 @@ export class ChatRepositoryMongodb implements IChatRepository { if (!doc) return null; return this.toDetails(doc); } + + async updateAgent(ticketId: string, agentId: string | null): Promise { + const doc = await this.chatModel + .findOneAndUpdate({ ticketId }, { agentId }, { new: true }) + .exec(); + if (!doc) return null; + return this.toDetails(doc); + } } diff --git a/backend/src/modules/ticket/application/useCases/create/create.usecase.spec.ts b/backend/src/modules/ticket/application/useCases/create/create.usecase.spec.ts index 256fc78..d061289 100644 --- a/backend/src/modules/ticket/application/useCases/create/create.usecase.spec.ts +++ b/backend/src/modules/ticket/application/useCases/create/create.usecase.spec.ts @@ -30,7 +30,13 @@ describe('CreateTicketUseCase', () => { repository = { create: jest.fn().mockResolvedValue(ticket), - }; + save: jest.fn(), + readAll: jest.fn(), + readById: jest.fn(), + delete: jest.fn(), + findByStatus: jest.fn(), + findByStatusCategory: jest.fn(), + } as unknown as jest.Mocked; useCase = new CreateTicketUseCase(repository, triageService); }); diff --git a/backend/src/modules/ticket/application/useCases/delete/delete.usecase.spec.ts b/backend/src/modules/ticket/application/useCases/delete/delete.usecase.spec.ts index 8e6c857..00215ff 100644 --- a/backend/src/modules/ticket/application/useCases/delete/delete.usecase.spec.ts +++ b/backend/src/modules/ticket/application/useCases/delete/delete.usecase.spec.ts @@ -10,7 +10,13 @@ describe('DeleteTicketUseCase', () => { beforeEach(() => { repository = { delete: jest.fn().mockResolvedValue(true), - }; + create: jest.fn(), + save: jest.fn(), + readAll: jest.fn(), + readById: jest.fn(), + findByStatus: jest.fn(), + findByStatusCategory: jest.fn(), + } as unknown as jest.Mocked; useCase = new DeleteTicketUseCase(repository); }); diff --git a/backend/src/modules/ticket/application/useCases/escalate/escalate.usecase.spec.ts b/backend/src/modules/ticket/application/useCases/escalate/escalate.usecase.spec.ts index 2da7f97..70ee01b 100644 --- a/backend/src/modules/ticket/application/useCases/escalate/escalate.usecase.spec.ts +++ b/backend/src/modules/ticket/application/useCases/escalate/escalate.usecase.spec.ts @@ -6,9 +6,11 @@ import { Ticket, TicketStatus, } from '../../../domain/entities/ticket.entity'; +import { ChatService } from '../../../../chat/application/chat.service'; describe('EscalateTicketUseCase', () => { let repository: jest.Mocked; + let chatService: jest.Mocked; let useCase: EscalateTicketUseCase; let ticket: Ticket; @@ -25,7 +27,11 @@ describe('EscalateTicketUseCase', () => { save: jest.fn(), } as unknown as jest.Mocked; - useCase = new EscalateTicketUseCase(repository); + chatService = { + updateAgentByTicketId: jest.fn(), + } as unknown as jest.Mocked; + + useCase = new EscalateTicketUseCase(repository, chatService); }); it('should escalate ticket successfully', async () => { @@ -33,6 +39,7 @@ describe('EscalateTicketUseCase', () => { id: ticket.id, groupId: randomUUID(), category: 'iot', + whatWasDone: 'Feito algo', }; ticket.assignToAgent(randomUUID()); @@ -47,7 +54,7 @@ describe('EscalateTicketUseCase', () => { expect(output).toBeDefined(); expect(output.id).toBe(ticket.id); expect(output.status).toBe(TicketStatus.ESCALATED); - expect(output.escalationLevel).toBe(2); + expect(output.escalationLevel).toBe(1); expect(output.updatedAt).toBeInstanceOf(Date); expect(repository.readById).toHaveBeenCalledWith(input.id); diff --git a/backend/src/modules/ticket/application/useCases/escalate/escalate.usecase.ts b/backend/src/modules/ticket/application/useCases/escalate/escalate.usecase.ts index 953c911..3cddc24 100644 --- a/backend/src/modules/ticket/application/useCases/escalate/escalate.usecase.ts +++ b/backend/src/modules/ticket/application/useCases/escalate/escalate.usecase.ts @@ -4,6 +4,7 @@ import { TicketStatus, } from '../../../domain/entities/ticket.entity'; import { ITicketRepository } from '../../../domain/repository/ticket.repository.interface'; +import { ChatService } from '../../../../chat/application/chat.service'; export interface EscalateTicketInput { id: string; @@ -28,7 +29,10 @@ export interface EscalateTicketOutput { @Injectable() export class EscalateTicketUseCase { - constructor(private readonly repository: ITicketRepository) {} + constructor( + private readonly repository: ITicketRepository, + private readonly chatService: ChatService, + ) {} async execute(input: EscalateTicketInput): Promise { const foundedTicket = await this.repository.readById(input.id); @@ -45,6 +49,12 @@ export class EscalateTicketUseCase { throw new Error('Ticket not escalated'); } + try { + await this.chatService.updateAgentByTicketId(escalatedTicket.id, escalatedTicket.agentId); + } catch (e) { + console.warn('Chat não pôde ser atualizado ou não existe:', e); + } + const primitive = escalatedTicket.toPrimitives(); return { diff --git a/backend/src/modules/ticket/application/useCases/newAgent/newAgent.usecase.spec.ts b/backend/src/modules/ticket/application/useCases/newAgent/newAgent.usecase.spec.ts index 5b18c3a..ff346f5 100644 --- a/backend/src/modules/ticket/application/useCases/newAgent/newAgent.usecase.spec.ts +++ b/backend/src/modules/ticket/application/useCases/newAgent/newAgent.usecase.spec.ts @@ -6,9 +6,11 @@ import { TicketStatus, } from '../../../domain/entities/ticket.entity'; import { NewAgentTicketUseCase } from './newAgent.usecase'; +import { ChatService } from '../../../../chat/application/chat.service'; describe('NewAgentTicketUseCase', () => { let repository: jest.Mocked; + let chatService: jest.Mocked; let useCase: NewAgentTicketUseCase; let ticket: Ticket; @@ -25,7 +27,11 @@ describe('NewAgentTicketUseCase', () => { save: jest.fn(), } as unknown as jest.Mocked; - useCase = new NewAgentTicketUseCase(repository); + chatService = { + updateAgentByTicketId: jest.fn(), + } as unknown as jest.Mocked; + + useCase = new NewAgentTicketUseCase(repository, chatService); }); it('should assing a new agent to a ticket successfully', async () => { diff --git a/backend/src/modules/ticket/application/useCases/newAgent/newAgent.usecase.ts b/backend/src/modules/ticket/application/useCases/newAgent/newAgent.usecase.ts index 273f04a..8f80b46 100644 --- a/backend/src/modules/ticket/application/useCases/newAgent/newAgent.usecase.ts +++ b/backend/src/modules/ticket/application/useCases/newAgent/newAgent.usecase.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { TicketStatus } from '../../../domain/entities/ticket.entity'; import { ITicketRepository } from '../../../domain/repository/ticket.repository.interface'; +import { ChatService } from '../../../../chat/application/chat.service'; export interface NewAgentTicketInput { id: string; @@ -15,7 +16,10 @@ export interface NewAgentTicketOutput { @Injectable() export class NewAgentTicketUseCase { - constructor(private readonly repository: ITicketRepository) {} + constructor( + private readonly repository: ITicketRepository, + private readonly chatService: ChatService, + ) {} async execute(input: NewAgentTicketInput): Promise { const foundedTicket = await this.repository.readById(input.id); @@ -32,6 +36,13 @@ export class NewAgentTicketUseCase { throw new Error('Fail to update ticket.'); } + try { + await this.chatService.updateAgentByTicketId(updatedTicket.id, updatedTicket.agentId); + } catch (e) { + // Ignora se o chat não existir, pois os módulos estão fracamente acoplados + console.warn('Chat não pôde ser atualizado ou não existe:', e); + } + return { id: updatedTicket.id, agentId: updatedTicket.agentId, diff --git a/backend/src/modules/ticket/domain/entities/tickect.entity.spec.ts b/backend/src/modules/ticket/domain/entities/tickect.entity.spec.ts index db9361d..224ec5f 100644 --- a/backend/src/modules/ticket/domain/entities/tickect.entity.spec.ts +++ b/backend/src/modules/ticket/domain/entities/tickect.entity.spec.ts @@ -84,7 +84,8 @@ describe('Ticket entity', () => { expect(primitiveTicket.groupId).toBe(newGroupId); expect(primitiveTicket.category).toBe('ia'); - expect(primitiveTicket.escalationLevel).toBe(2); + expect(primitiveTicket.escalationLevel).toBe(1); + expect(primitiveTicket.agentId).toBeNull(); expect(primitiveTicket.updatedAt).toBeInstanceOf(Date); expect(primitiveTicket.updatedAt).not.toBeNull(); }); diff --git a/backend/src/modules/ticket/domain/entities/ticket.entity.ts b/backend/src/modules/ticket/domain/entities/ticket.entity.ts index 3003405..2bb2c78 100644 --- a/backend/src/modules/ticket/domain/entities/ticket.entity.ts +++ b/backend/src/modules/ticket/domain/entities/ticket.entity.ts @@ -230,23 +230,31 @@ export class Ticket { throw new Error(TicketValidationErrors.ECALATE_WITH_NO_AGENT_ERROR); } - if (this.escalationLevel >= 3) { - throw new Error(TicketValidationErrors.ESCALATION_LEVEL_MAX_ERROR); - } - this.touch(); this._groupId = groupId; - this.category = category ?? this.category; - this.escalationLevel++; + + if (category && category !== this.category) { + this.category = category; + this.escalationLevel = 1; + } else { + if (this.escalationLevel >= 3) { + throw new Error(TicketValidationErrors.ESCALATION_LEVEL_MAX_ERROR); + } + this.escalationLevel++; + } + + const previousAgentId = this._agentId; + this._agentId = null; + this._status = TicketStatus.ESCALATED; this.addHistory({ event: TicketEvents.ESCALATE, - responsibleAgent: this._agentId, + responsibleAgent: previousAgentId, status: TicketStatus.ESCALATED, message: TicketEventMessage.ESCALATE_MSG, - solution: whatWasDone ?? null + solution: whatWasDone ?? null, }); } diff --git a/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts b/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts index bb10e04..9c01828 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts @@ -115,6 +115,7 @@ describe('TicketController', () => { clientId: primitives.clientId, fileUrls: primitives.fileUrls, status: primitives.status, + level: primitives.escalationLevel, createdAt: primitives.createdAt, }); @@ -133,6 +134,7 @@ describe('TicketController', () => { clientId: primitives.clientId, fileUrls: primitives.fileUrls, status: primitives.status, + level: primitives.escalationLevel, }), ); @@ -304,9 +306,8 @@ describe('TicketController', () => { description: primitives.category, clientId: ticket.clientId, status: primitives.status, - agentId: agentId, - groupId: groupId, - escalationLevel: primitives.escalationLevel, + agentId: null, + escalationLevel: 1, createdAt: primitives.createdAt, updatedAt: primitives.updatedAt, }); @@ -331,9 +332,8 @@ describe('TicketController', () => { description: primitives.category, clientId: ticket.clientId, status: primitives.status, - agentId: agentId, - groupId: groupId, - escalationLevel: 2, + agentId: null, + escalationLevel: 1, updatedAt: primitives.updatedAt?.toISOString(), }), ); diff --git a/backend/src/modules/ticket/ticket.module.ts b/backend/src/modules/ticket/ticket.module.ts index ca9bfdd..6898a3d 100644 --- a/backend/src/modules/ticket/ticket.module.ts +++ b/backend/src/modules/ticket/ticket.module.ts @@ -16,6 +16,7 @@ import { DeleteTicketUseCase } from './application/useCases/delete/delete.usecas import { NewAgentTicketUseCase } from './application/useCases/newAgent/newAgent.usecase'; import { TriageModule } from '../triage/triage.module'; import { CloseTicketUseCase } from './application/useCases/close/close.usecase'; +import { ChatModule } from '../chat/chat.module'; @Module({ imports: [ @@ -23,6 +24,7 @@ import { CloseTicketUseCase } from './application/useCases/close/close.usecase'; { name: TicketSchemaClass.name, schema: TicketSchema }, ]), TriageModule, + ChatModule, ], controllers: [TicketController], providers: [ From 01bfc2b158c65e050e493c10666fb253edc97b8c Mon Sep 17 00:00:00 2001 From: DigoCast Date: Fri, 1 May 2026 07:10:57 -0300 Subject: [PATCH 2/2] fix: check if AgentId is null, and change idCategory verification --- .../chat/infra/chat.repository.mongodb.ts | 36 +++++++++---------- .../presentation/dtos/escalateTicket.dto.ts | 4 +-- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/backend/src/modules/chat/infra/chat.repository.mongodb.ts b/backend/src/modules/chat/infra/chat.repository.mongodb.ts index 840adae..d980d36 100644 --- a/backend/src/modules/chat/infra/chat.repository.mongodb.ts +++ b/backend/src/modules/chat/infra/chat.repository.mongodb.ts @@ -39,44 +39,42 @@ export class ChatRepositoryMongodb implements IChatRepository { async findById(id: string): Promise { const chatDoc = await this.chatModel.findById(id).exec(); - + if (!chatDoc) return null; - return { id: chatDoc._id as string, - ticketId: chatDoc.ticketId.toString(), - clientId: chatDoc.clientId.toString(), - agentId: chatDoc.agentId.toString(), - groupId: chatDoc.groupId.toString(), + ticketId: chatDoc.ticketId?.toString() || '', + clientId: chatDoc.clientId?.toString() || '', + agentId: chatDoc.agentId?.toString() || '', + groupId: chatDoc.groupId?.toString() || '', status: chatDoc.status as any, }; } - async findByParticipant(userId: string): Promise { - const docs = await this.chatModel - .find({ - $or: [{ clientId: userId }, { agentId: userId }], - }) - .sort({ createdAt: -1 }) - .exec(); - return docs.map((doc) => this.toDetails(doc)); - } - async findByTicketId(ticketId: string): Promise { const chatDoc = await this.chatModel.findOne({ ticketId }).exec(); - + if (!chatDoc) return null; - return { id: chatDoc._id as string, ticketId: chatDoc.ticketId?.toString() || '', clientId: chatDoc.clientId?.toString() || '', - agentId: chatDoc.agentId?.toString() || '', + agentId: chatDoc.agentId?.toString() || '', groupId: chatDoc.groupId?.toString() || '', status: chatDoc.status as any, }; } + async findByParticipant(userId: string): Promise { + const docs = await this.chatModel + .find({ + $or: [{ clientId: userId }, { agentId: userId }], + }) + .sort({ createdAt: -1 }) + .exec(); + return docs.map((doc) => this.toDetails(doc)); + } + async updateStatus( id: string, status: ChatStatus, diff --git a/backend/src/modules/ticket/presentation/dtos/escalateTicket.dto.ts b/backend/src/modules/ticket/presentation/dtos/escalateTicket.dto.ts index 64e21ee..218a430 100644 --- a/backend/src/modules/ticket/presentation/dtos/escalateTicket.dto.ts +++ b/backend/src/modules/ticket/presentation/dtos/escalateTicket.dto.ts @@ -1,10 +1,10 @@ -import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; +import { IsNotEmpty, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { randomUUID } from 'crypto'; export class EscalateTicketRequest { @ApiProperty({ example: randomUUID(), description: 'ID do grupo' }) - @IsUUID() + @IsString() @IsNotEmpty() groupId!: string;