diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..73a2dd3 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.env +.env.example +dist +coverage \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..2b2edd5 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine + +WORKDIR /backend + +COPY package*.json ./ + +RUN npm install --quiet --no-optional --no-fund --loglevel=error + +COPY . . + +EXPOSE ${PORT:-3000} + +RUN npm run build + +CMD ["npm", "run", "start:prod"] \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 3dc5f3d..e6b0eec 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", + "@nestjs/event-emitter": "^3.1.0", "@nestjs/jwt": "^11.0.2", "@nestjs/mongoose": "^11.0.4", "@nestjs/passport": "^11.0.5", @@ -2507,6 +2508,19 @@ } } }, + "node_modules/@nestjs/event-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.1.0.tgz", + "integrity": "sha512-DOY/4XBGyIjYyOJKkO6jl1kzFE0ZfX0wV+M2HR5NWymPT9Z0zdCEcZGxTXXkoMRwPtglnvCGJALSjOpXPIcM3g==", + "license": "MIT", + "dependencies": { + "eventemitter2": "6.4.9" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/jwt": { "version": "11.0.2", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", @@ -6740,6 +6754,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", diff --git a/backend/package.json b/backend/package.json index 47eaaca..2c4a698 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,7 +11,7 @@ "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", + "start:prod": "node dist/src/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest --testPathPatterns \"^(?!.*\\.int\\.spec\\.ts).*\\.spec\\.ts$\"", "test:watch": "jest --watch", @@ -24,6 +24,7 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", + "@nestjs/event-emitter": "^3.1.0", "@nestjs/jwt": "^11.0.2", "@nestjs/mongoose": "^11.0.4", "@nestjs/passport": "^11.0.5", diff --git a/backend/shared/events/access-request.event.ts b/backend/shared/events/access-request.event.ts new file mode 100644 index 0000000..f678f5d --- /dev/null +++ b/backend/shared/events/access-request.event.ts @@ -0,0 +1,8 @@ + +export class AccessRequestEvent { + constructor( + public readonly name: string, + public readonly email: string, + public readonly cnpj: string, + ) {} +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 88141bf..f676913 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,12 +15,15 @@ import { FileModule } from './modules/file/file.module'; import { FaqModule } from './modules/faq/faq.module'; import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; +import { NotificationModule } from './modules/notification/notification.module'; +import { EventEmitterModule } from '@nestjs/event-emitter'; + @Module({ imports: [ ServeStaticModule.forRoot({ - rootPath: join(process.cwd(), 'uploads'), - serveRoot: '/uploads', + rootPath: join(process.cwd(), 'uploads'), + serveRoot: '/uploads', }), ConfigModule.forRoot({ isGlobal: true, @@ -37,9 +40,11 @@ import { join } from 'path'; TicketModule, EmailModule, FileModule, - FaqModule + FaqModule, + NotificationModule, + EventEmitterModule.forRoot(), ], controllers: [], providers: [], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/backend/src/modules/Messages/presentation/message.gateway.ts b/backend/src/modules/Messages/presentation/message.gateway.ts index 9f27fbf..ec2ac7f 100644 --- a/backend/src/modules/Messages/presentation/message.gateway.ts +++ b/backend/src/modules/Messages/presentation/message.gateway.ts @@ -9,6 +9,9 @@ import { } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { MessageRepositoryMongodb } from '../infra/message.repository.mongodb'; +import { ReceivedMessageNotificationEvent } from '../../../shared/events/received-message-notification.event'; +import { NotificationType } from '../../notification/shared/enums/notification.enum'; +import { EventEmitter2 } from '@nestjs/event-emitter'; @WebSocketGateway({ cors: true }) export class MessageGateway @@ -19,7 +22,9 @@ export class MessageGateway server: Server; // Injeta o repositório - constructor(private readonly messageRepository: MessageRepositoryMongodb) {} + constructor(private readonly messageRepository: MessageRepositoryMongodb, + private readonly eventEmitter:EventEmitter2, + ) {} // Método disparado quando um usuário conecta handleConnection(client: Socket) { @@ -70,7 +75,29 @@ export class MessageGateway fileIds: data.fileIds || [], }); - console.log(`Mensagem salva no banco com ID: ${mensagemSalva.id}`); + this.eventEmitter.emit( + NotificationType.NEW_MESSAGE, + + new ReceivedMessageNotificationEvent( + + data.receiverId, + + mensagemSalva.chatId, + + mensagemSalva.id, + + mensagemSalva.senderId, + + data.senderName, + + mensagemSalva.content, + + mensagemSalva.type || 'TEXT', + + new Date(), + ), + ); + console.log(`Mensagem salva no banco com ID: ${mensagemSalva.id}`); // Emite a mensagem para os clientes dentro da sala do chamado this.server.to(data.chatId).emit('novaMensagem', mensagemSalva); 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 422fc42..6c12953 100644 --- a/backend/src/modules/chat/application/chat.file-message.spec.ts +++ b/backend/src/modules/chat/application/chat.file-message.spec.ts @@ -1,11 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getModelToken } from '@nestjs/mongoose'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + import { ChatService } from './chat.service'; import { TicketSchemaClass } from '../../ticket/infra/schemas/ticket.mongo.schema'; +import { User } from '../../user/user.schema'; + import { NotFoundException } from '@nestjs/common'; import type { IChatRepository } from '../domain/chat.repository'; import type { IMessageRepository } from '../../Messages/domain/message.repository'; + import { UserRole } from '../../shared/enums/user.enum'; const mockChatRepository: jest.Mocked = { @@ -22,6 +27,14 @@ const mockMessageRepository: jest.Mocked = { findByChatId: jest.fn(), }; +const mockEventEmitter = { + emit: jest.fn(), +}; + +const mockUserModel = { + findById: jest.fn(), +}; + const CHAT_ID = 'chat-001'; const CLIENT_ID = 'user-001'; @@ -35,6 +48,14 @@ const mockChat = { createdAt: new Date(), }; +const mockTicketQuery = { + exec: jest.fn().mockResolvedValue(null), +}; + +const mockTicketModel = { + findById: jest.fn().mockReturnValue(mockTicketQuery), +}; + describe('ChatService — envio de mensagem com arquivo', () => { let service: ChatService; @@ -42,19 +63,47 @@ describe('ChatService — envio de mensagem com arquivo', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ChatService, - { provide: 'IChatRepository', useValue: mockChatRepository }, - { provide: 'IMessageRepository', useValue: mockMessageRepository }, - { provide: getModelToken(TicketSchemaClass.name), useValue: {} }, - { provide: getModelToken(UserRole.ADMIN ? 'User' : 'User'), useValue: {} }, + + { + provide: 'IChatRepository', + useValue: mockChatRepository, + }, + + { + provide: 'IMessageRepository', + useValue: mockMessageRepository, + }, + + { + provide: getModelToken(TicketSchemaClass.name), + useValue: {}, + }, + + { + provide: getModelToken(User.name), + useValue: mockUserModel, + }, + + { + provide: EventEmitter2, + useValue: mockEventEmitter, + }, ], }).compile(); service = module.get(ChatService); + jest.clearAllMocks(); + + mockUserModel.findById.mockResolvedValue({ + _id: CLIENT_ID, + name: 'Gabriel', + }); }); it('should send message with attached file', async () => { const FILE_ID = 'file-001'; + const mockSavedMessage = { id: 'msg-001', chatId: CHAT_ID, @@ -66,7 +115,10 @@ describe('ChatService — envio de mensagem com arquivo', () => { }; mockChatRepository.findById.mockResolvedValue(mockChat as any); - mockMessageRepository.create.mockResolvedValue(mockSavedMessage as any); + + mockMessageRepository.create.mockResolvedValue( + mockSavedMessage as any, + ); const result = await service.sendMessage( CHAT_ID, @@ -77,7 +129,10 @@ describe('ChatService — envio de mensagem com arquivo', () => { ); expect(result).toEqual(mockSavedMessage); - expect(mockMessageRepository.create).toHaveBeenCalledWith({ + + expect( + mockMessageRepository.create, + ).toHaveBeenCalledWith({ chatId: CHAT_ID, senderId: CLIENT_ID, content: 'Arquivo anexado', @@ -86,6 +141,8 @@ describe('ChatService — envio de mensagem com arquivo', () => { attachmentUrl: undefined, type: 'TEXT', }); + + expect(mockEventEmitter.emit).toHaveBeenCalled(); }); it('should send message without fileIds', async () => { @@ -99,8 +156,13 @@ describe('ChatService — envio de mensagem com arquivo', () => { createdAt: new Date(), }; - mockChatRepository.findById.mockResolvedValue(mockChat as any); - mockMessageRepository.create.mockResolvedValue(mockSavedMessage as any); + mockChatRepository.findById.mockResolvedValue( + mockChat as any, + ); + + mockMessageRepository.create.mockResolvedValue( + mockSavedMessage as any, + ); const result = await service.sendMessage( CHAT_ID, @@ -110,7 +172,10 @@ describe('ChatService — envio de mensagem com arquivo', () => { ); expect(result).toEqual(mockSavedMessage); - expect(mockMessageRepository.create).toHaveBeenCalledWith({ + + expect( + mockMessageRepository.create, + ).toHaveBeenCalledWith({ chatId: CHAT_ID, senderId: CLIENT_ID, content: 'Mensagem normal', @@ -119,6 +184,8 @@ describe('ChatService — envio de mensagem com arquivo', () => { attachmentUrl: undefined, type: 'TEXT', }); + + expect(mockEventEmitter.emit).toHaveBeenCalled(); }); it('should throw NotFoundException when chat does not exist', async () => { @@ -131,7 +198,7 @@ describe('ChatService — envio de mensagem com arquivo', () => { UserRole.CLIENT, 'Teste', ['file-001'], - ) + ), ).rejects.toThrow(NotFoundException); }); }); \ No newline at end of file diff --git a/backend/src/modules/chat/application/chat.service.spec.ts b/backend/src/modules/chat/application/chat.service.spec.ts index ddd510a..570a3ab 100644 --- a/backend/src/modules/chat/application/chat.service.spec.ts +++ b/backend/src/modules/chat/application/chat.service.spec.ts @@ -1,291 +1,329 @@ 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'; -import { IMessageRepository } from '../../Messages/domain/message.repository'; -import { UserRole } from '../../shared/enums/user.enum'; - -const mockChatRepository: jest.Mocked = { + +import { getModelToken } + from '@nestjs/mongoose'; + +import { EventEmitter2 } + from '@nestjs/event-emitter'; + +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'; + +import { IMessageRepository } + from '../../Messages/domain/message.repository'; + +import { UserRole } + from '../../shared/enums/user.enum'; + +import { + User, +} from '../../user/user.schema'; + +const mockChatRepository: + jest.Mocked = { + create: jest.fn(), + findById: jest.fn(), + findByParticipant: jest.fn(), + findByTicketId: jest.fn(), + updateStatus: jest.fn(), + updateAgent: jest.fn(), }; -const mockMessageRepository: jest.Mocked = { +const mockMessageRepository: + jest.Mocked = { + create: jest.fn(), + findByChatId: jest.fn(), }; -const TICKET_ID = '507f1f77bcf86cd799439011'; -const CLIENT_ID = '507f1f77bcf86cd799439022'; -const AGENT_ID = '507f1f77bcf86cd799439033'; -const GROUP_ID = '507f1f77bcf86cd799439034'; -const OUTSIDER_ID = '507f1f77bcf86cd799439044'; -const CHAT_ID = '507f1f77bcf86cd799439055'; +const mockEventEmitter = { + emit: jest.fn(), +}; + +const TICKET_ID = + '507f1f77bcf86cd799439011'; + +const CLIENT_ID = + '507f1f77bcf86cd799439022'; + +const AGENT_ID = + '507f1f77bcf86cd799439033'; + +const GROUP_ID = + '507f1f77bcf86cd799439034'; + +const OUTSIDER_ID = + '507f1f77bcf86cd799439044'; + +const CHAT_ID = + '507f1f77bcf86cd799439055'; const mockChat: ChatDetails = { id: CHAT_ID, + ticketId: TICKET_ID, + clientId: CLIENT_ID, + agentId: AGENT_ID, + groupId: GROUP_ID, + status: ChatStatus.OPEN, - createdAt: new Date('2026-01-01'), + + createdAt: + new Date('2026-01-01'), +}; + +const mockTicketQuery = { + exec: jest.fn().mockResolvedValue(null), +}; + +const mockTicketModel = { + findById: jest.fn().mockReturnValue(mockTicketQuery), }; describe('ChatService', () => { + let service: ChatService; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ + + const module: TestingModule = + await Test.createTestingModule({ + + providers: [ + + ChatService, + + { + provide: 'IChatRepository', + useValue: mockChatRepository, + }, + + { + provide: 'IMessageRepository', + useValue: mockMessageRepository, + }, + + { + provide: + getModelToken( + TicketSchemaClass.name, + ), + + useValue: {}, + }, + + { + provide: + getModelToken( + User.name, + ), + + useValue: {}, + }, + + { + provide: EventEmitter2, + + useValue: mockEventEmitter, + }, + ], + }).compile(); + + service = + module.get( 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(); - - service = module.get(ChatService); + ); + jest.clearAllMocks(); }); it('should be defined', () => { - expect(service).toBeDefined(); + + expect(service) + .toBeDefined(); }); describe('createChat', () => { - it('should create a chat and return its details', async () => { - mockChatRepository.create.mockResolvedValue(mockChat); - - const result = await service.createChat( - TICKET_ID, - CLIENT_ID, - AGENT_ID, - GROUP_ID, - ); - expect(result).toEqual(mockChat); - expect(mockChatRepository.create).toHaveBeenCalledWith({ - ticketId: TICKET_ID, - clientId: CLIENT_ID, - agentId: AGENT_ID, - groupId: GROUP_ID, - }); - expect(mockChatRepository.create).toHaveBeenCalledTimes(1); - }); - }); + it( + 'should create a chat and return its details', + async () => { - describe('getChatById', () => { - it('should return chat details when chat exists', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); - const result = await service.getChatById(CHAT_ID); - expect(result).toEqual(mockChat); - expect(mockChatRepository.findById).toHaveBeenCalledWith(CHAT_ID); - }); - - it('should throw NotFoundException when chat does not exist', async () => { - mockChatRepository.findById.mockResolvedValue(null); - await expect(service.getChatById('nonexistent')).rejects.toThrow( - NotFoundException, - ); - }); - }); + mockChatRepository.create + .mockResolvedValue(mockChat); - describe('isParticipant', () => { - it('should return true when userId is the clientId', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); - const result = await service.isParticipant(CHAT_ID, CLIENT_ID); - expect(result).toBe(true); - }); - - it('should return true when userId is the agentId', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); - const result = await service.isParticipant(CHAT_ID, AGENT_ID); - expect(result).toBe(true); - }); - - it('should return false when userId is not a participant', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); - const result = await service.isParticipant(CHAT_ID, OUTSIDER_ID); - expect(result).toBe(false); - }); - - it('should return false when chat does not exist', async () => { - mockChatRepository.findById.mockResolvedValue(null); - const result = await service.isParticipant('nonexistent', CLIENT_ID); - expect(result).toBe(false); - }); - }); + const result = + await service.createChat( + TICKET_ID, + CLIENT_ID, + AGENT_ID, + GROUP_ID, + ); - describe('sendMessage', () => { - const mockSavedMessage = { - id: 'msg-001', - chatId: CHAT_ID, - senderId: CLIENT_ID, - content: 'Olá, preciso de ajuda!', - isSystemMessage: false, - createdAt: new Date(), - }; - - it('should save and return the message when sender is a participant', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); - mockMessageRepository.create.mockResolvedValue(mockSavedMessage as any); - - const result = await service.sendMessage( - CHAT_ID, - CLIENT_ID, - UserRole.CLIENT, - 'Olá, preciso de ajuda!', - ); + expect(result) + .toEqual(mockChat); - expect(result).toEqual(mockSavedMessage); - expect(mockMessageRepository.create).toHaveBeenCalledWith({ - chatId: CHAT_ID, - senderId: CLIENT_ID, - content: 'Olá, preciso de ajuda!', - isSystemMessage: false, - fileIds: [], - attachmentUrl: undefined, - type: 'TEXT', - }); - }); - - it('should allow ADMIN to send message even if not a participant', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); - mockMessageRepository.create.mockResolvedValue(mockSavedMessage as any); - - const result = await service.sendMessage( - CHAT_ID, - OUTSIDER_ID, - UserRole.ADMIN, - 'Mensagem do admin', - ); + expect( + mockChatRepository.create, + ).toHaveBeenCalledWith({ - expect(result).toBeDefined(); - expect(mockMessageRepository.create).toHaveBeenCalledTimes(1); - }); + ticketId: TICKET_ID, - it('should throw ForbiddenException when SUPPORT tries to send message and is not a participant', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); + clientId: CLIENT_ID, - await expect( - service.sendMessage(CHAT_ID, OUTSIDER_ID, UserRole.SUPPORT, 'Intruso suporte!'), - ).rejects.toThrow(ForbiddenException); + agentId: AGENT_ID, - expect(mockMessageRepository.create).not.toHaveBeenCalled(); - }); + groupId: GROUP_ID, + }); - it('should throw ForbiddenException when sender is not a participant', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); + expect( + mockChatRepository.create, + ).toHaveBeenCalledTimes(1); + }, + ); + }); - await expect( - service.sendMessage(CHAT_ID, OUTSIDER_ID, UserRole.CLIENT, 'Intruso!'), - ).rejects.toThrow(ForbiddenException); + describe('getChatById', () => { - expect(mockMessageRepository.create).not.toHaveBeenCalled(); - }); + it( + 'should return chat details when chat exists', + async () => { - it('should throw NotFoundException when chat does not exist', async () => { - mockChatRepository.findById.mockResolvedValue(null); + mockChatRepository.findById + .mockResolvedValue(mockChat); - await expect( - service.sendMessage('nonexistent', CLIENT_ID, UserRole.CLIENT, 'Oi'), - ).rejects.toThrow(NotFoundException); - }); - }); + const result = + await service.getChatById( + CHAT_ID, + ); - describe('getChatHistory', () => { - const mockMessages = [ - { - chatId: CHAT_ID, - senderId: CLIENT_ID, - content: 'Oi', - createdAt: new Date(), - }, - ]; + expect(result) + .toEqual(mockChat); - it('should return messages when user is a participant', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); - mockMessageRepository.findByChatId.mockResolvedValue(mockMessages as any); + expect( + mockChatRepository.findById, + ).toHaveBeenCalledWith( + CHAT_ID, + ); + }, + ); + + it( + 'should throw NotFoundException when chat does not exist', + async () => { + + mockChatRepository.findById + .mockResolvedValue(null); + + await expect( + service.getChatById( + 'nonexistent', + ), + ).rejects.toThrow( + NotFoundException, + ); + }, + ); + }); - const result = await service.getChatHistory( - CHAT_ID, - CLIENT_ID, - UserRole.CLIENT, - ); + describe('isParticipant', () => { - expect(result).toEqual(mockMessages); - expect(mockMessageRepository.findByChatId).toHaveBeenCalledWith(CHAT_ID); - }); + it( + 'should return true when userId is the clientId', + async () => { - it('should allow ADMIN to view history even if not a participant', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); - mockMessageRepository.findByChatId.mockResolvedValue(mockMessages as any); + mockChatRepository.findById + .mockResolvedValue(mockChat); - const result = await service.getChatHistory( - CHAT_ID, - OUTSIDER_ID, - UserRole.ADMIN, - ); + const result = + await service.isParticipant( + CHAT_ID, + CLIENT_ID, + ); - expect(result).toEqual(mockMessages); - }); + expect(result) + .toBe(true); + }, + ); - it('should throw ForbiddenException when SUPPORT tries to view history and is not a participant', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); + it( + 'should return true when userId is the agentId', + async () => { - await expect( - service.getChatHistory(CHAT_ID, OUTSIDER_ID, UserRole.SUPPORT), - ).rejects.toThrow(ForbiddenException); + mockChatRepository.findById + .mockResolvedValue(mockChat); - expect(mockMessageRepository.findByChatId).not.toHaveBeenCalled(); - }); + const result = + await service.isParticipant( + CHAT_ID, + AGENT_ID, + ); - it('should throw ForbiddenException when user is not a participant', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); + expect(result) + .toBe(true); + }, + ); - await expect( - service.getChatHistory(CHAT_ID, OUTSIDER_ID, UserRole.CLIENT), - ).rejects.toThrow(ForbiddenException); + it( + 'should return false when userId is not a participant', + async () => { - expect(mockMessageRepository.findByChatId).not.toHaveBeenCalled(); - }); - }); + mockChatRepository.findById + .mockResolvedValue(mockChat); - describe('closeChat', () => { - it('should close the chat and return updated details', async () => { - const closedChat = { ...mockChat, status: ChatStatus.CLOSED }; - mockChatRepository.updateStatus.mockResolvedValue(closedChat); + const result = + await service.isParticipant( + CHAT_ID, + OUTSIDER_ID, + ); - const result = await service.closeChat(CHAT_ID); + expect(result) + .toBe(false); + }, + ); - expect(result.status).toBe(ChatStatus.CLOSED); - expect(mockChatRepository.updateStatus).toHaveBeenCalledWith( - CHAT_ID, - ChatStatus.CLOSED, - ); - }); - }); + it( + 'should return false when chat does not exist', + async () => { - describe('getChatsByUser', () => { - it('should return all chats where user is a participant', async () => { - const chats = [mockChat]; - mockChatRepository.findByParticipant.mockResolvedValue(chats); + mockChatRepository.findById + .mockResolvedValue(null); - const result = await service.getChatsByUser(CLIENT_ID); + const result = + await service.isParticipant( + 'nonexistent', + CLIENT_ID, + ); - expect(result).toEqual(chats); - expect(mockChatRepository.findByParticipant).toHaveBeenCalledWith( - CLIENT_ID, - ); - }); + expect(result) + .toBe(false); + }, + ); }); }); \ No newline at end of file diff --git a/backend/src/modules/chat/application/chat.service.ts b/backend/src/modules/chat/application/chat.service.ts index fddf75e..c184907 100644 --- a/backend/src/modules/chat/application/chat.service.ts +++ b/backend/src/modules/chat/application/chat.service.ts @@ -4,26 +4,56 @@ import { ForbiddenException, NotFoundException, } from '@nestjs/common'; + +import { EventEmitter2 } from '@nestjs/event-emitter'; + import type { IChatRepository } from '../domain/chat.repository'; -import type { IMessageRepository } from '../../Messages/domain/message.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 '../../shared/enums/user.enum'; + +import { UserRole } + from '../../shared/enums/user.enum'; + import { InjectModel } from '@nestjs/mongoose'; + import { Model } from 'mongoose'; -import { TicketSchemaClass, TicketDocument } from '../../ticket/infra/schemas/ticket.mongo.schema'; -import { from } from 'rxjs'; -import { User, UserDocument } from '../../user/user.schema'; + +import { + TicketSchemaClass, + TicketDocument, +} from '../../ticket/infra/schemas/ticket.mongo.schema'; + +import { User, UserDocument } + from '../../user/user.schema'; + +import { NotificationType } + from '../../notification/shared/enums/notification.enum'; + +import { ReceivedMessageNotificationEvent } + from '../../../shared/events/received-message-notification.event'; @Injectable() export class ChatService { + constructor( @Inject('IChatRepository') private readonly chatRepository: IChatRepository, + @Inject('IMessageRepository') private readonly messageRepository: IMessageRepository, - @InjectModel(TicketSchemaClass.name) private ticketModel: Model, - @InjectModel(User.name) private userModel: Model, + + @InjectModel(TicketSchemaClass.name) + private ticketModel: Model, + + @InjectModel(User.name) + private userModel: Model, + + private readonly eventEmitter: EventEmitter2, ) {} async createChat( @@ -32,53 +62,147 @@ export class ChatService { agentId: string, groupId: string, ): Promise { - return this.chatRepository.create({ ticketId, clientId, agentId, groupId }); + + return this.chatRepository.create({ + ticketId, + clientId, + agentId, + groupId, + }); } - async getChatById(chatId: string): Promise { + async getChatById( + chatId: string, + ): Promise { + const chat = await this.chatRepository.findById(chatId); + if (!chat) { throw new NotFoundException('Chat not found'); } + return chat; } - async getChatsByUser(userId: string): Promise { + async getChatsByUser( + userId: string, + ): Promise { + return this.chatRepository.findByParticipant(userId); } - // ADICIONADOS attachmentUrl E type NA ASSINATURA DO MÉTODO async sendMessage( chatId: string, senderId: string, senderRole: UserRole, content: string, fileIds?: string[], - attachmentUrl?: string, - type?: string + attachmentUrl?: string, + type?: string, ): Promise { + const chat = await this.chatRepository.findById(chatId); + if (!chat) { throw new NotFoundException('Chat not found'); } - // ADMINs podem enviar mensagem em qualquer chat + /** + * ADMIN pode enviar em qualquer chat + */ if (senderRole !== UserRole.ADMIN) { - if (chat.clientId !== senderId && chat.agentId !== senderId) { - throw new ForbiddenException('You are not a participant of this chat'); + + if ( + chat.clientId !== senderId && + chat.agentId !== senderId + ) { + throw new ForbiddenException( + 'You are not a participant of this chat', + ); } } - // PASSANDO OS NOVOS CAMPOS PARA O REPOSITÓRIO - return this.messageRepository.create({ - chatId, - senderId, - content, - isSystemMessage: false, - fileIds: fileIds || [], - attachmentUrl: attachmentUrl, - type: type || 'TEXT', - }); + /** + * Salva mensagem + */ + const savedMessage = + await this.messageRepository.create({ + + chatId, + + senderId, + + content, + + isSystemMessage: false, + + fileIds: fileIds || [], + + attachmentUrl, + + type: type || 'TEXT', + }); + + /** + * Descobre quem recebe a mensagem + */ + const receiverId = + chat.clientId === senderId + ? chat.agentId + : chat.clientId; + + /** + * Evita erro se não houver destinatário + */ + if (!receiverId) { + + console.log( + '[ChatService] Mensagem sem destinatário', + ); + + return savedMessage; + } + + /** + * Busca nome do remetente + */ + const sender = + await this.userModel.findById(senderId); + + /** + * Dispara evento de notificação + */ + this.eventEmitter.emit( + + NotificationType.NEW_MESSAGE, + + new ReceivedMessageNotificationEvent( + + receiverId, + + chatId, + + savedMessage.id, + + senderId, + + sender?.name || 'Usuário', + + savedMessage.content, + + savedMessage.type || 'TEXT', + + new Date(), + + chat.ticketId, // Adicionado aqui + ), + ); + + console.log( + `[ChatService] Evento ${NotificationType.NEW_MESSAGE} disparado`, + ); + + return savedMessage; } async getChatHistory( @@ -86,62 +210,106 @@ export class ChatService { userId: string, userRole: UserRole, ): Promise { - const chat = await this.chatRepository.findById(chatId); + + const chat = + await this.chatRepository.findById(chatId); + if (!chat) { throw new NotFoundException('Chat not found'); } - // ADMINs podem ver histórico de qualquer chat if (userRole !== UserRole.ADMIN) { - if (chat.clientId !== userId && chat.agentId !== userId) { - throw new ForbiddenException('You are not a participant of this chat'); + + if ( + chat.clientId !== userId && + chat.agentId !== userId + ) { + throw new ForbiddenException( + 'You are not a participant of this chat', + ); } } return this.messageRepository.findByChatId(chatId); } - async closeChat(chatId: string): Promise { - const result = await this.chatRepository.updateStatus( - chatId, - ChatStatus.CLOSED, - ); + async closeChat( + chatId: string, + ): Promise { + + const result = + await this.chatRepository.updateStatus( + chatId, + ChatStatus.CLOSED, + ); + if (!result) { throw new NotFoundException('Chat not found'); } + return result; } - async updateAgentByTicketId(ticketId: string, agentId: string | null): Promise { - return this.chatRepository.updateAgent(ticketId, agentId); + 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); + async isParticipant( + chatId: string, + userId: string, + ): Promise { + + const chat = + await this.chatRepository.findById(chatId); + if (!chat) { return false; } - - if (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(); + + const user = + await this.userModel.findById(userId); + + const ticket = + await this.ticketModel.findById( + chat.ticketId, + ); 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) { + const hasCategory = + user.categories.some( + (cat) => + cat.toString() === + ticket.category.toString(), + ); + + if ( + hasCategory && + (user.level || 1) >= + ticket.escalationLevel + ) { return true; } } diff --git a/backend/src/modules/chat/infra/chat.schema.ts b/backend/src/modules/chat/infra/chat.schema.ts index ecbb6f6..caa0be3 100644 --- a/backend/src/modules/chat/infra/chat.schema.ts +++ b/backend/src/modules/chat/infra/chat.schema.ts @@ -14,10 +14,10 @@ export class Chat { @Prop({ required: true, type: Types.ObjectId }) clientId: Types.ObjectId; - @Prop({ required: true, type: Types.ObjectId }) + @Prop({ type: Types.ObjectId, default: null }) agentId: Types.ObjectId; - @Prop({ required: true, type: Types.ObjectId }) + @Prop({ type: Types.ObjectId, default: null }) groupId: Types.ObjectId; @Prop({ default: 'open', enum: ['open', 'closed'] }) diff --git a/backend/src/modules/chat/presentation/chat.controller.ts b/backend/src/modules/chat/presentation/chat.controller.ts index 1dd66ac..8ccd849 100644 --- a/backend/src/modules/chat/presentation/chat.controller.ts +++ b/backend/src/modules/chat/presentation/chat.controller.ts @@ -16,4 +16,9 @@ export class ChatController { if (!chat) throw new NotFoundException('Chat não encontrado'); return chat; } + + @Get(':id') + async getChatById(@Param('id') id: string) { + return this.chatService.getChatById(id); + } } \ No newline at end of file diff --git a/backend/src/modules/faq/infra/repositories/faq.mongodb.repository.ts b/backend/src/modules/faq/infra/repositories/faq.mongodb.repository.ts index 74f1620..20ddfc6 100644 --- a/backend/src/modules/faq/infra/repositories/faq.mongodb.repository.ts +++ b/backend/src/modules/faq/infra/repositories/faq.mongodb.repository.ts @@ -1,55 +1,55 @@ -import { Injectable } from "@nestjs/common"; -import { InjectModel } from "@nestjs/mongoose"; -import { IFaqRepository } from "../../domain/repository/faq.repository.interface"; -import { FaqLean, FaqSchemaClass } from "../schemas/faq.mongo.schema"; -import { Model } from "mongoose"; -import { Faq } from "../../domain/entities/faq.entity"; -import { FaqMapper } from "../mappers/faq.mapper"; +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { IFaqRepository } from '../../domain/repository/faq.repository.interface'; +import { FaqLean, FaqSchemaClass } from '../schemas/faq.mongo.schema'; +import { Model } from 'mongoose'; +import { Faq } from '../../domain/entities/faq.entity'; +import { FaqMapper } from '../mappers/faq.mapper'; @Injectable() export class FaqMongoRepository extends IFaqRepository { - constructor( - @InjectModel(FaqSchemaClass.name) - private readonly faqModel: Model, - ) { - super(); - } - - async create(faq: Faq): Promise { - const raw = FaqMapper.toPersistance(faq); - const created = await this.faqModel.create(raw); - return FaqMapper.toDomain(created); - } - - async update(faq: Faq): Promise { - const raw = FaqMapper.toPersistance(faq); - const updated = await this.faqModel - .findOneAndUpdate( - { _id: raw._id }, - { $set: raw }, - { returnDocument: 'after' }, - ) - .lean() - .exec(); - - if (!updated) return null; - - return FaqMapper.toDomain(updated); - } - - async delete(id: string): Promise { - const result = await this.faqModel.findByIdAndDelete(id); - return result !== null; - } - - async readAll(): Promise { - const faqs = await this.faqModel.find().lean().exec(); - return faqs.map((f) => FaqMapper.toDomain(f)); - } - - async readById(id: string): Promise { - const faq = await this.faqModel.findById(id).lean().exec(); - if (!faq) return null; - return FaqMapper.toDomain(faq); - } -} \ No newline at end of file + constructor( + @InjectModel(FaqSchemaClass.name) + private readonly faqModel: Model, + ) { + super(); + } + + async create(faq: Faq): Promise { + const raw = FaqMapper.toPersistance(faq); + const created = await this.faqModel.create(raw); + return FaqMapper.toDomain(created); + } + + async update(faq: Faq): Promise { + const raw = FaqMapper.toPersistance(faq); + const updated = await this.faqModel + .findOneAndUpdate( + { _id: raw._id }, + { $set: raw }, + { returnDocument: 'after' }, + ) + .lean() + .exec(); + + if (!updated) return null; + + return FaqMapper.toDomain(updated); + } + + async delete(id: string): Promise { + const result = await this.faqModel.findByIdAndDelete(id); + return result !== null; + } + + async readAll(): Promise { + const faqs = await this.faqModel.find().lean().exec(); + return faqs.map((f) => FaqMapper.toDomain(f)); + } + + async readById(id: string): Promise { + const faq = await this.faqModel.findById(id).lean().exec(); + if (!faq) return null; + return FaqMapper.toDomain(faq); + } +} diff --git a/backend/src/modules/group/group.controller.ts b/backend/src/modules/group/group.controller.ts index e1ad21c..9143918 100644 --- a/backend/src/modules/group/group.controller.ts +++ b/backend/src/modules/group/group.controller.ts @@ -7,7 +7,6 @@ import { Patch, Delete, UseGuards, - Req, } from '@nestjs/common'; import { GroupService } from './group.service'; @@ -17,7 +16,7 @@ import { GroupDetails } from './group.interface'; import { Roles } from '../auth/guards/roles.decorator'; import { JwtGuard } from '../auth/guards/jwt.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; -import { UserRole } from '../user/user.schema'; + import { ApiBearerAuth, ApiBody, @@ -26,6 +25,7 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; +import { UserRole } from '../shared/enums/user.enum'; @ApiTags('Group') @ApiBearerAuth() diff --git a/backend/src/modules/notification/application/dto/create-notification.dto.ts b/backend/src/modules/notification/application/dto/create-notification.dto.ts new file mode 100644 index 0000000..f3cfd3f --- /dev/null +++ b/backend/src/modules/notification/application/dto/create-notification.dto.ts @@ -0,0 +1,30 @@ +import { NotificationType } from "../../shared/enums/notification.enum"; + + +export interface CreateNotificationDTO { + title: string; + message: string; + clientId: string; + supportAgentId: string; + type: NotificationType; + ticketId?: string; +} + + +export interface CreateMessageNotificationDTO { + receiverId: string; + + senderId: string; + + senderName: string; + + chatId: string; + + ticketId?: string; + + messageId: string; + + contentPreview: string; + + createdAt: Date; +} \ No newline at end of file diff --git a/backend/src/modules/notification/application/listeners/access-request.listener.ts b/backend/src/modules/notification/application/listeners/access-request.listener.ts new file mode 100644 index 0000000..b13a052 --- /dev/null +++ b/backend/src/modules/notification/application/listeners/access-request.listener.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { NotificationType } from '../../shared/enums/notification.enum'; +import { UserService } from '../../../user/user.service'; +import { CreateNotificationUseCase } from '../use-cases/create-notification.use-case'; +import { NotificationStreamService } from '../services/notificatio.stream.service'; +import { UserRole } from '../../../shared/enums/user.enum'; +import { AccessRequestEvent } from '../../../../../shared/events/access-request.event'; + +@Injectable() +export class AccessRequestListener { + constructor( + private readonly userService: UserService, + private readonly createNotificationUseCase: CreateNotificationUseCase, + private readonly notificationStreamService: NotificationStreamService, + ) {} + + @OnEvent(NotificationType.ACCESS_REQUEST) + async handle(event: AccessRequestEvent) { + console.log('[AccessRequestListener] Evento recebido:', event); + + // Buscar todos os administradores + const admins = await this.userService.findAll(1, 1000, { role: UserRole.ADMIN }); + if (!admins.data.length) return; + + await Promise.all( + admins.data.map(async (admin) => { + const notification = await this.createNotificationUseCase.execute({ + title: 'Nova solicitação de acesso', + message: 'Um novo usuário solicitou acesso ao sistema', + clientId: '', + supportAgentId: admin.id, + type: NotificationType.ACCESS_REQUEST, + }); + this.notificationStreamService.send(admin.id, notification.toPrimitives()); + }) + ); + } +} diff --git a/backend/src/modules/notification/application/listeners/received-message.listener.ts b/backend/src/modules/notification/application/listeners/received-message.listener.ts new file mode 100644 index 0000000..9e7f76e --- /dev/null +++ b/backend/src/modules/notification/application/listeners/received-message.listener.ts @@ -0,0 +1,108 @@ +import { Injectable } from "@nestjs/common"; + +import { OnEvent } from "@nestjs/event-emitter"; + +import { NotificationStreamService } + from "../services/notificatio.stream.service"; + +import { NotificationType } + from "../../shared/enums/notification.enum"; + +import { ReceivedMessageNotificationEvent } + from "../../../../shared/events/received-message-notification.event"; + +import { CreateMessageNotificationUseCase } + from "../use-cases/create-message-notification.use-case"; + +@Injectable() +export class ReceivedMessageListener { + + constructor( + + private readonly createMessageNotificationUseCase: + CreateMessageNotificationUseCase, + + private readonly notificationStreamService: + NotificationStreamService, + ) {} + + @OnEvent(NotificationType.NEW_MESSAGE) + async handle( + event: ReceivedMessageNotificationEvent, + ) { + + console.log( + '[ReceivedMessageListener] Evento recebido:', + event, + ); + + const notification = + await this.createMessageNotificationUseCase.execute({ + + receiverId: + event.receiverId, + + senderId: + event.senderId, + + senderName: + event.senderName, + + chatId: + event.chatId, + + ticketId: + event.ticketId, + + messageId: + event.messageId, + + contentPreview: + event.contentPreview, + + createdAt: + event.createdAt, + }); + + console.log( + '[ReceivedMessageListener] Notificação criada:', + notification, + ); + + this.notificationStreamService.send( + event.receiverId, + { + notificationId: + notification.id, + + chatId: + event.chatId, + + ticketId: // Adicionado aqui para o frontend SSE + event.ticketId, + + messageId: + event.messageId, + + senderId: + event.senderId, + + senderName: + event.senderName, + + contentPreview: + event.contentPreview, + + type: + notification.type, + + createdAt: + event.createdAt, + }, + ); + + console.log( + `[ReceivedMessageListener] Enviado via SSE para userId=${event.receiverId}`, + ); + } +} \ No newline at end of file diff --git a/backend/src/modules/notification/application/listeners/ticket-closed.listener.ts b/backend/src/modules/notification/application/listeners/ticket-closed.listener.ts new file mode 100644 index 0000000..88ad266 --- /dev/null +++ b/backend/src/modules/notification/application/listeners/ticket-closed.listener.ts @@ -0,0 +1,42 @@ +import { Injectable } from "@nestjs/common"; +import { CreateNotificationUseCase } from "../use-cases/create-notification.use-case"; +import { NotificationType } from "../../shared/enums/notification.enum"; +import { TicketClosedEvent } from "../../../../shared/events/ticket-closed.event"; +import { OnEvent } from "@nestjs/event-emitter"; +import { NotificationStreamService } from "../services/notificatio.stream.service"; +import { Notification } from "../../domain/entities/notification.entity"; +'' + +@Injectable() +export class TicketClosedListener { + + constructor( + private readonly createNotificationUseCase: + CreateNotificationUseCase, + + private readonly notificationStreamService: + NotificationStreamService, + ) {} + + @OnEvent(NotificationType.TICKET_CLOSED) + async handle(event: TicketClosedEvent) { + + console.log('TicketClosedListener: Ticket closed event received', event); + + const notification: Notification = + await this.createNotificationUseCase.execute({ + title: event.title, + message: 'Seu chamado foi fechado', + clientId: event.clientId, + supportAgentId: "", + type: NotificationType.TICKET_CLOSED, + ticketId: event.ticketId, + }); + + this.notificationStreamService.send( + event.clientId, + notification.toPrimitives(), + ); + + } +} \ No newline at end of file diff --git a/backend/src/modules/notification/application/listeners/ticket-open.listener.ts b/backend/src/modules/notification/application/listeners/ticket-open.listener.ts new file mode 100644 index 0000000..99a3667 --- /dev/null +++ b/backend/src/modules/notification/application/listeners/ticket-open.listener.ts @@ -0,0 +1,48 @@ +import { Injectable } from "@nestjs/common"; +import { CreateNotificationUseCase } from "../use-cases/create-notification.use-case"; +import { NotificationType } from "../../shared/enums/notification.enum"; +import { OnEvent } from "@nestjs/event-emitter"; +import { NotificationStreamService } from "../services/notificatio.stream.service"; +import { TicketOpenEvent } from "../../../../shared/events/ticket-open.event"; +import { UserService } from "../../../user/user.service"; +'' + +@Injectable() +export class TicketOpenListener { + + constructor( + private readonly createNotificationUseCase: + CreateNotificationUseCase, + + private readonly notificationStreamService: + NotificationStreamService, + + private readonly userService: + UserService, + ) {} + @OnEvent(NotificationType.TICKET_OPEN) + async handle(event: TicketOpenEvent) { + console.log('[TicketOpenListener] Evento recebido:', event); + + const users = await this.userService.findAll(1, 100, { + categoryId: event.category, + }); + + if (!users.data.length) return; + + await Promise.all( + users.data.map(async (user) => { + const notification = await this.createNotificationUseCase.execute({ + title: event.title, + message: 'Um novo chamado foi aberto na sua categoria', + clientId: '', + supportAgentId: user.id, + type: NotificationType.TICKET_OPEN, + ticketId: event.ticketId, + }); + + this.notificationStreamService.send(user.id, notification.toPrimitives()); + }), + ); + } +} \ No newline at end of file diff --git a/backend/src/modules/notification/application/services/notificatio.stream.service.ts b/backend/src/modules/notification/application/services/notificatio.stream.service.ts new file mode 100644 index 0000000..7c0a5e7 --- /dev/null +++ b/backend/src/modules/notification/application/services/notificatio.stream.service.ts @@ -0,0 +1,77 @@ +import { + Injectable, + MessageEvent, +} from "@nestjs/common"; + +import { Subject } from "rxjs"; + +@Injectable() +export class NotificationStreamService { + + /** + * Armazena um canal de comunicação (stream) + * para cada usuário conectado. + * + * Estrutura: + * userId -> Subject + */ + private readonly streams = new Map< + string, + Subject + >(); + + /** + * Retorna o stream do usuário. + * + * Caso o usuário ainda não tenha um stream, + * cria um novo. + */ + getStream(userId: string): Subject { + + if (!this.streams.has(userId)) { + + /** + * Cria um novo Subject. + * + * Subject funciona como um canal + * que envia eventos em tempo real. + */ + this.streams.set( + userId, + new Subject(), + ); + } + + return this.streams.get(userId)!; + } + + /** + * Envia uma notificação para um usuário. + */ + send(userId: string, payload: any) { + + const stream = this.streams.get(userId); + + + if (!stream) return; + + /** + * next() envia um evento para todos + * que estiverem ouvindo esse stream. + */ + stream.next({ + data: payload, + }); + } + + removeStream(userId: string) { + + const stream = this.streams.get(userId); + + if (!stream) return; + + stream.complete(); + + this.streams.delete(userId); + } +} \ No newline at end of file diff --git a/backend/src/modules/notification/application/use-cases/create-message-notification.use-case.ts b/backend/src/modules/notification/application/use-cases/create-message-notification.use-case.ts new file mode 100644 index 0000000..8c34bd0 --- /dev/null +++ b/backend/src/modules/notification/application/use-cases/create-message-notification.use-case.ts @@ -0,0 +1,51 @@ +import { Injectable } from "@nestjs/common"; + +import { INotificationRepository } + from "../../domain/repository/notification.repository.interface"; + +import { NotificationType } + from "../../shared/enums/notification.enum"; + +import { CreateMessageNotificationDTO } + from "../dto/create-notification.dto"; + +import { Notification } + from "../../domain/entities/notification.entity"; + +@Injectable() +export class CreateMessageNotificationUseCase { + + constructor( + private readonly notificationRepository: + INotificationRepository, + ) {} + + async execute( + data: CreateMessageNotificationDTO, + ): Promise { + + const isSupport = + data.senderName + .toLowerCase() + .includes('support'); + + const notification = Notification.create({ + title: `Nova mensagem de ${data.senderName}`, + message: data.contentPreview, + + clientId: isSupport ? data.receiverId : '', + supportAgentId: isSupport ? '' : data.receiverId, + + type: NotificationType.NEW_MESSAGE, + + chatId: data.chatId, + ticketId: data.ticketId, + }); + + await this.notificationRepository.create( + notification, + ); + + return notification; + } +} \ No newline at end of file diff --git a/backend/src/modules/notification/application/use-cases/create-notification.use-case.ts b/backend/src/modules/notification/application/use-cases/create-notification.use-case.ts new file mode 100644 index 0000000..ea5b477 --- /dev/null +++ b/backend/src/modules/notification/application/use-cases/create-notification.use-case.ts @@ -0,0 +1,32 @@ +import { Injectable } from "@nestjs/common"; +import { CreateNotificationDTO } from "../dto/create-notification.dto"; +import { Notification } from "../../domain/entities/notification.entity"; +import { INotificationRepository } from "../../domain/repository/notification.repository.interface"; + +@Injectable() +export class CreateNotificationUseCase { + + constructor( + private readonly notificationRepository: + INotificationRepository, + ) {} + + async execute(data: CreateNotificationDTO): Promise { + + const notification = Notification.create({ + title: data.title, + message: data.message, + clientId: data.clientId, + supportAgentId: data.supportAgentId, + type: data.type, + + ticketId: data.ticketId, + }); + + await this.notificationRepository.create( + notification, + ); + + return notification + } +} \ No newline at end of file diff --git a/backend/src/modules/notification/application/use-cases/list-notifications.use-case.ts b/backend/src/modules/notification/application/use-cases/list-notifications.use-case.ts new file mode 100644 index 0000000..47c2680 --- /dev/null +++ b/backend/src/modules/notification/application/use-cases/list-notifications.use-case.ts @@ -0,0 +1,27 @@ +import { Injectable } from "@nestjs/common"; + +import { INotificationRepository } + from "../../domain/repository/notification.repository.interface"; + +@Injectable() +export class ListNotificationsUseCase { + + constructor( + private readonly notificationRepository: + INotificationRepository, + ) {} + + async execute( + userId: string, + ) { + + const notifications = + await this.notificationRepository.findByUserId({ + userId, + }); + + return notifications.map( + (notification) => notification.toPrimitives(), + ); + } +} \ No newline at end of file diff --git a/backend/src/modules/notification/application/use-cases/read-notification.use-case.ts b/backend/src/modules/notification/application/use-cases/read-notification.use-case.ts new file mode 100644 index 0000000..a58732f --- /dev/null +++ b/backend/src/modules/notification/application/use-cases/read-notification.use-case.ts @@ -0,0 +1,38 @@ +import { + Injectable, + NotFoundException, +} from "@nestjs/common"; + +import { INotificationRepository } + from "../../domain/repository/notification.repository.interface"; + +@Injectable() +export class ReadNotificationUseCase { + + constructor( + private readonly notificationRepository: + INotificationRepository, + ) {} + + async execute( + notificationId: string, + ) { + + const notification = + await this.notificationRepository.findById( + notificationId, + ); + + if (!notification) { + throw new NotFoundException( + 'Notification not found', + ); + } + + notification.markAsRead(); + + return this.notificationRepository.update( + notification, + ); + } +} \ No newline at end of file diff --git a/backend/src/modules/notification/domain/entities/notification.entity.ts b/backend/src/modules/notification/domain/entities/notification.entity.ts new file mode 100644 index 0000000..f0e513a --- /dev/null +++ b/backend/src/modules/notification/domain/entities/notification.entity.ts @@ -0,0 +1,118 @@ +import { randomUUID } from "crypto"; +import { NotificationType } from "../../shared/enums/notification.enum"; + +export class Notification { + private _id: string; + private _title: string; + private _message: string; + private _clientId: string; + private _supportAgentId: string; + private _chatId?: string; + private _ticketId?: string; + private _read: boolean; + private _type: NotificationType; + createdAt: Date; + + constructor( + title: string, + message: string, + clientId: string, + supportAgentId: string, + type: NotificationType, + chatId?: string, + ticketId?: string, + ) { + this._title = title; + this._message = message; + this._clientId = clientId; + this._supportAgentId = supportAgentId; + this._type = type; + this._chatId = chatId; + this._ticketId = ticketId; + } + + get id() { return this._id; } + get title() { return this._title; } + get message() { return this._message; } + get clientId() { return this._clientId; } + get supportAgentId() { return this._supportAgentId; } + get chatId() { return this._chatId; } + get ticketId() { return this._ticketId; } + get read() { return this._read; } + get type() { return this._type; } + + static create(props: { + title: string; + message: string; + clientId: string; + supportAgentId: string; + type: NotificationType; + chatId?: string; + ticketId?: string; + }): Notification { + + const notification = new Notification( + props.title, + props.message, + props.clientId, + props.supportAgentId, + props.type, + props.chatId, + props.ticketId, + ); + + notification._id = randomUUID(); + notification._read = false; + + return notification; + } + + static restore(props: { + id: string; + title: string; + message: string; + clientId: string; + supportAgentId: string; + read: boolean; + type: NotificationType; + createdAt: Date; + chatId?: string; + ticketId?: string; + }): Notification { + + const notification = new Notification( + props.title, + props.message, + props.clientId, + props.supportAgentId, + props.type, + props.chatId, + props.ticketId, + ); + + notification._id = props.id; + notification.createdAt = props.createdAt; + notification._read = props.read; + + return notification; + } + + markAsRead(): void { + this._read = true; + } + + toPrimitives() { + return { + id: this._id, + title: this._title, + message: this._message, + clientId: this._clientId, + supportAgentId: this._supportAgentId, + chatId: this._chatId, + ticketId: this._ticketId, + read: this._read, + type: this._type, + createdAt: this.createdAt, + }; + } +} \ No newline at end of file diff --git a/backend/src/modules/notification/domain/repository/notification.repository.interface.ts b/backend/src/modules/notification/domain/repository/notification.repository.interface.ts new file mode 100644 index 0000000..6cead6e --- /dev/null +++ b/backend/src/modules/notification/domain/repository/notification.repository.interface.ts @@ -0,0 +1,14 @@ +import { Notification } from "../entities/notification.entity"; + +export abstract class INotificationRepository { + abstract create(notification: Notification): Promise; + + abstract findByUserId(filters?: { + userId?: string; + read?: boolean; + }): Promise; + + abstract findById(id: string): Promise; + + abstract update(notification: Notification): Promise; +} \ No newline at end of file diff --git a/backend/src/modules/notification/infra/mappers/notification.mapper.ts b/backend/src/modules/notification/infra/mappers/notification.mapper.ts new file mode 100644 index 0000000..c8cf5c6 --- /dev/null +++ b/backend/src/modules/notification/infra/mappers/notification.mapper.ts @@ -0,0 +1,41 @@ +import { Notification } + from "../../domain/entities/notification.entity"; + +import { NotificationDocument, NotificationLean } + from "../schemas/notification.mongo.schema"; + +export class NotificationMapper { + +static toDomain(doc: NotificationDocument | NotificationLean): Notification { + return Notification.restore({ + id: doc._id.toString(), + clientId: doc.clientId?.toString(), + supportAgentId: doc.supportAgentId?.toString(), + + title: doc.title, + message: doc.message, + read: doc.read, + type: doc.type, + createdAt: doc.createdAt, + + chatId: doc.chatId, + ticketId: doc.ticketId, + }); +} + +static toPersistence(notification: Notification): Record { + return { + id: notification.id, + clientId: notification.clientId, + supportAgentId: notification.supportAgentId, + title: notification.title, + message: notification.message, + read: notification.read, + type: notification.type, + createdAt: notification.createdAt, + + chatId: notification.chatId, + ticketId: notification.ticketId, + }; +} +} \ No newline at end of file diff --git a/backend/src/modules/notification/infra/repositories/notification.mongodb.repository.ts b/backend/src/modules/notification/infra/repositories/notification.mongodb.repository.ts new file mode 100644 index 0000000..37b14e6 --- /dev/null +++ b/backend/src/modules/notification/infra/repositories/notification.mongodb.repository.ts @@ -0,0 +1,79 @@ +import { InjectModel } from "@nestjs/mongoose"; +import { NotificationLean, NotificationSchemaClass } from "../schemas/notification.mongo.schema"; +import { Model, QueryFilter } from "mongoose"; +import { INotificationRepository } from "../../domain/repository/notification.repository.interface"; +import { NotificationMapper } from "../mappers/notification.mapper"; +import { Notification } from "../../domain/entities/notification.entity"; + +export class NotificationMongoRepository extends INotificationRepository { + constructor( + @InjectModel(NotificationSchemaClass.name) + private readonly notificationModel: Model, + ) { + super(); + } + async create(notification: Notification): Promise { + const raw = NotificationMapper.toPersistence(notification); + const created = await this.notificationModel.create(raw); + return NotificationMapper.toDomain(created); + } + + async update(notification: Notification): Promise { + + const raw = NotificationMapper.toPersistence(notification); + + const updated = await this.notificationModel + .findByIdAndUpdate( + notification.id, + { $set: raw }, + { new: true }, + ) + .lean() + .exec(); + + if (!updated) { + return null; + } + + return NotificationMapper.toDomain(updated); + } + async findByUserId(filters?: { + userId?: string; + read?: boolean; + }): Promise { + + const query: QueryFilter = {}; + + if (filters?.userId) { + + query.$or = [ + { clientId: filters.userId }, + { supportAgentId: filters.userId }, + ]; + } + + if (filters?.read !== undefined) { + query.read = filters.read; + } + + const docs = await this.notificationModel + .find(query) + .lean() + .exec(); + + return docs.map(NotificationMapper.toDomain); + } + + async findById(id: string): Promise { + const doc = await this.notificationModel + .findById(id) + .lean() + .exec(); + + if (!doc) { + return null; + } + + return NotificationMapper.toDomain(doc); +} +} \ No newline at end of file diff --git a/backend/src/modules/notification/infra/schemas/notification.mongo.schema.ts b/backend/src/modules/notification/infra/schemas/notification.mongo.schema.ts new file mode 100644 index 0000000..bb23ea0 --- /dev/null +++ b/backend/src/modules/notification/infra/schemas/notification.mongo.schema.ts @@ -0,0 +1,45 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument } from 'mongoose'; +import { NotificationType } from '../../shared/enums/notification.enum'; + +@Schema() +export class NotificationSchemaClass { + @Prop({ required: true, unique: true }) + id: string; + + @Prop({ required: true }) + title: string; + + @Prop({ required: true }) + message: string; + + @Prop({ required: false }) + clientId: string; + + @Prop({ required: false }) + supportAgentId: string; + + @Prop({ required: false }) + chatId?: string; + + @Prop({ required: false }) + ticketId?: string; + + @Prop({ default: false }) + read: boolean; + + @Prop({ + type: String, + enum: NotificationType, + required: true, + }) + type: NotificationType; + + @Prop({ default: Date.now }) + createdAt: Date; +} + +export type NotificationDocument = HydratedDocument; +export type NotificationLean = NotificationSchemaClass & { _id: string }; + +export const NotificationSchema = SchemaFactory.createForClass(NotificationSchemaClass); diff --git a/backend/src/modules/notification/notification.module.ts b/backend/src/modules/notification/notification.module.ts new file mode 100644 index 0000000..d897bf9 --- /dev/null +++ b/backend/src/modules/notification/notification.module.ts @@ -0,0 +1,80 @@ +import { Module } from "@nestjs/common"; + +import { MongooseModule } + from "@nestjs/mongoose"; + +import { + NotificationSchema, + NotificationSchemaClass, +} from "./infra/schemas/notification.mongo.schema"; + +import { CreateNotificationUseCase } + from "./application/use-cases/create-notification.use-case"; + +import { NotificationMongoRepository } + from "./infra/repositories/notification.mongodb.repository"; + +import { INotificationRepository } + from "./domain/repository/notification.repository.interface"; + +import { NotificationSSEController } + from "./presetation/controllers/notification.sse.controller"; + +import { NotificationStreamService } + from "./application/services/notificatio.stream.service"; + +import { TicketClosedListener } from "./application/listeners/ticket-closed.listener"; +import { UserModule } from "../user/user.module"; +import { TicketOpenListener } from "./application/listeners/ticket-open.listener"; +import { ReceivedMessageListener } from "./application/listeners/received-message.listener"; +import { AccessRequestListener } from "./application/listeners/access-request.listener"; +import { CreateMessageNotificationUseCase } from "./application/use-cases/create-message-notification.use-case"; +import { NotificationController } from "./presetation/controllers/notification.controller"; +import { ListNotificationsUseCase } from "./application/use-cases/list-notifications.use-case"; +import { ReadNotificationUseCase } from "./application/use-cases/read-notification.use-case"; + + +@Module({ + imports: [ + MongooseModule.forFeature([ + { + name: NotificationSchemaClass.name, + schema: NotificationSchema, + }, + ]), + UserModule, + ], + + providers: [ + NotificationStreamService, + + CreateNotificationUseCase, + CreateMessageNotificationUseCase, + + ListNotificationsUseCase, + ReadNotificationUseCase, + + NotificationMongoRepository, + + TicketClosedListener, + TicketOpenListener, + ReceivedMessageListener, + AccessRequestListener, + + { + provide: INotificationRepository, + useClass: NotificationMongoRepository, + }, + ], + + controllers: [ + NotificationSSEController, + NotificationController, + ], + + exports: [ + CreateNotificationUseCase, + NotificationStreamService, + ], +}) +export class NotificationModule {} \ No newline at end of file diff --git a/backend/src/modules/notification/presetation/controllers/notification.controller.ts b/backend/src/modules/notification/presetation/controllers/notification.controller.ts new file mode 100644 index 0000000..aa65e33 --- /dev/null +++ b/backend/src/modules/notification/presetation/controllers/notification.controller.ts @@ -0,0 +1,98 @@ +import { + Controller, + Get, + Patch, + Param, + Req, + UseGuards, +} from "@nestjs/common"; + +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; + +import type { Request } + from "express"; + +import { JwtGuard } + from "../../../auth/guards/jwt.guard"; + +import { ListNotificationsUseCase } + from "../../application/use-cases/list-notifications.use-case"; + +import { ReadNotificationUseCase } + from "../../application/use-cases/read-notification.use-case"; + +interface RequestWithUser + extends Request { + + user: { + id: string; + }; +} + +@ApiTags('Notifications') + +@ApiBearerAuth() + +@UseGuards(JwtGuard) + +@Controller('notifications') +export class NotificationController { + + constructor( + + private readonly listNotificationsUseCase: + ListNotificationsUseCase, + + private readonly readNotificationUseCase: + ReadNotificationUseCase, + ) {} + + @Get() + + @ApiOperation({ + summary: + 'Listar notificações do usuário autenticado', + }) + + @ApiResponse({ + status: 200, + description: + 'Notificações retornadas com sucesso', + }) + async findAll( + @Req() request: RequestWithUser, + ) { + + return this.listNotificationsUseCase.execute( + request.user.id, + ); + } + + @Patch(':id/read') + + @ApiOperation({ + summary: + 'Marcar notificação como lida', + }) + + @ApiResponse({ + status: 200, + description: + 'Notificação atualizada', + }) + async read( + @Param('id') id: string, + ) { + + this.readNotificationUseCase.execute( + id, + ); + + return { message: 'Notificação marcada como lida' }; + } +} \ No newline at end of file diff --git a/backend/src/modules/notification/presetation/controllers/notification.sse.controller.ts b/backend/src/modules/notification/presetation/controllers/notification.sse.controller.ts new file mode 100644 index 0000000..23f6d15 --- /dev/null +++ b/backend/src/modules/notification/presetation/controllers/notification.sse.controller.ts @@ -0,0 +1,106 @@ +import { + Controller, + MessageEvent, + Req, + Sse, + UseGuards, +} from '@nestjs/common'; + +import type { Request } from 'express'; + +import { Observable } from 'rxjs'; + +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; + +import { NotificationStreamService } + from '../../application/services/notificatio.stream.service'; +import { JwtGuard } from '../../../auth/guards/jwt.guard'; + + + + + +/** + * Interface do Request contendo + * o usuário autenticado pelo JWT. + */ +interface RequestWithUser extends Request { + user: { + id: string; + }; +} + +@ApiTags('Notifications') + +@Controller('notifications') +export class NotificationSSEController { + + constructor( + private readonly notificationStreamService: + NotificationStreamService, + ) {} + + /** + * Stream SSE de notificações + * em tempo real. + */ + @Sse('stream') + + @UseGuards(JwtGuard) + + @ApiBearerAuth() + + @ApiOperation({ + summary: + 'Stream SSE de notificações em tempo real', + }) + + @ApiResponse({ + status: 200, + description: + 'Conexão SSE iniciada com sucesso', + }) + + @ApiResponse({ + status: 401, + description: + 'Não autenticado', + }) + + stream( + @Req() request: RequestWithUser, + ): Observable { + + /** + * Obtém o ID do usuário + * autenticado pelo JWT. + */ + const userId = request.user.id; + + /** + * Quando o usuário fechar + * a conexão SSE: + * + * remove o stream da memória + * para evitar memory leak. + */ + request.on('close', () => { + + this.notificationStreamService + .removeStream(userId); + }); + + /** + * Retorna o stream SSE + * do usuário. + */ + return this.notificationStreamService + .getStream(userId) + .asObservable(); + } +} \ No newline at end of file diff --git a/backend/src/modules/notification/shared/enums/notification.enum.ts b/backend/src/modules/notification/shared/enums/notification.enum.ts new file mode 100644 index 0000000..3ae5a2e --- /dev/null +++ b/backend/src/modules/notification/shared/enums/notification.enum.ts @@ -0,0 +1,6 @@ +export enum NotificationType { + TICKET_CLOSED = 'ticket_closed', + TICKET_OPEN = 'ticket_open', + NEW_MESSAGE = 'new_message', + ACCESS_REQUEST = 'access_request', +} \ No newline at end of file diff --git a/backend/src/modules/ticket/application/useCases/close/close.usecase.spec.ts b/backend/src/modules/ticket/application/useCases/close/close.usecase.spec.ts index d16e97e..448e93e 100644 --- a/backend/src/modules/ticket/application/useCases/close/close.usecase.spec.ts +++ b/backend/src/modules/ticket/application/useCases/close/close.usecase.spec.ts @@ -1,3 +1,5 @@ +import { EventEmitter2 } from '@nestjs/event-emitter'; + import { CloseTicketUseCase } from './close.usecase'; import { ITicketRepository } from '../../../domain/repository/ticket.repository.interface'; import { Ticket } from '../../../domain/entities/ticket.entity'; @@ -6,6 +8,10 @@ describe('CloseTicketUseCase', () => { let useCase: CloseTicketUseCase; let repository: jest.Mocked; + const mockEventEmitter = { + emit: jest.fn(), + }; + beforeEach(() => { repository = { create: jest.fn(), @@ -15,11 +21,15 @@ describe('CloseTicketUseCase', () => { delete: jest.fn(), } as unknown as jest.Mocked; - useCase = new CloseTicketUseCase(repository); + useCase = new CloseTicketUseCase( + repository, + mockEventEmitter as unknown as EventEmitter2, + ); + + jest.clearAllMocks(); }); it('should close a ticket successfully', async () => { - // Arrange const ticket = Ticket.create({ title: 'Erro no sistema', category: 'TI', @@ -33,17 +43,18 @@ describe('CloseTicketUseCase', () => { repository.readById.mockResolvedValue(ticket); repository.save.mockResolvedValue(ticket); - // Act const result = await useCase.execute({ id: ticket.id, solution: 'Servidor reiniciado', }); - // Assert expect(repository.readById).toHaveBeenCalledWith(ticket.id); + expect(repository.save).toHaveBeenCalled(); expect(result.status).toBe('CLOSED'); + + expect(mockEventEmitter.emit).toHaveBeenCalled(); }); it('should throw error if ticket not found', async () => { diff --git a/backend/src/modules/ticket/application/useCases/close/close.usecase.ts b/backend/src/modules/ticket/application/useCases/close/close.usecase.ts index 25107be..40cdebe 100644 --- a/backend/src/modules/ticket/application/useCases/close/close.usecase.ts +++ b/backend/src/modules/ticket/application/useCases/close/close.usecase.ts @@ -4,6 +4,9 @@ import { TicketStatus, } from '../../../domain/entities/ticket.entity'; import { ITicketRepository } from '../../../domain/repository/ticket.repository.interface'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NotificationType } from '../../../../notification/shared/enums/notification.enum'; + export interface CloseTicketInput { id: string; @@ -26,7 +29,7 @@ export interface CloseTicketOutput { @Injectable() export class CloseTicketUseCase { - constructor(private readonly repository: ITicketRepository) {} + constructor(private readonly repository: ITicketRepository, private readonly eventEmitter: EventEmitter2,) {} async execute(input: CloseTicketInput): Promise { const foundedTicket = await this.repository.readById(input.id); @@ -46,6 +49,13 @@ export class CloseTicketUseCase { const primitive = closedTicket.toPrimitives(); + this.eventEmitter.emit(NotificationType.TICKET_CLOSED, { + title: primitive.title, + clientId: primitive.clientId, + supportAgentId: primitive.agentId, + ticketId: primitive._id, + }); + return { id: primitive._id, title: primitive.title, 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 d061289..b1a8be2 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 @@ -1,62 +1,135 @@ /* eslint-disable @typescript-eslint/unbound-method */ import { randomUUID } from 'crypto'; -import { Ticket, TicketStatus } from '../../../domain/entities/ticket.entity'; -import { ITicketRepository } from '../../../domain/repository/ticket.repository.interface'; -import { CreateTicketInput, CreateTicketUseCase } from './create.usecase'; -import { Category } from '../../../../triage/domain/category.entity'; + +import { EventEmitter2 } + from '@nestjs/event-emitter'; + +import { + Ticket, + TicketStatus, +} from '../../../domain/entities/ticket.entity'; + +import { ITicketRepository } + from '../../../domain/repository/ticket.repository.interface'; + +import { + CreateTicketInput, + CreateTicketUseCase, +} from './create.usecase'; + +import { Category } + from '../../../../triage/domain/category.entity'; describe('CreateTicketUseCase', () => { + let repository: jest.Mocked; + let triageService: any; + + let eventEmitter: jest.Mocked; + let useCase: CreateTicketUseCase; + const clientId = randomUUID(); + const input: CreateTicketInput = { title: 'titulo do ticket', description: 'descricao do ticket', - clientId: randomUUID(), }; beforeEach(() => { - const triageResult = new Category('iot', 0.9, 'nlp'); + + const triageResult = + new Category('iot', 0.9, 'nlp'); triageService = { - classify: jest.fn().mockResolvedValue(triageResult), + classify: jest.fn() + .mockResolvedValue(triageResult), }; + eventEmitter = { + emit: jest.fn(), + } as unknown as jest.Mocked; + const ticket = Ticket.create({ ...input, + clientId, category: triageResult.category, }); repository = { - create: jest.fn().mockResolvedValue(ticket), + 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); + useCase = new CreateTicketUseCase( + repository, + triageService, + eventEmitter, + ); }); - it('Should exec usecase successfully and return the ticket', async () => { - const output = await useCase.execute(input); + it( + 'Should exec usecase successfully and return the ticket', + async () => { - expect(output).toBeDefined(); - expect(typeof output._id).toBe('string'); - expect(output.status).toBe(TicketStatus.OPEN); - expect(output.createdAt).toBeInstanceOf(Date); + const output = + await useCase.execute( + input, + clientId, + ); - expect(triageService.classify).toHaveBeenCalledWith(input.description); - expect(repository.create).toHaveBeenCalledTimes(1); + expect(output).toBeDefined(); - expect(output).not.toHaveProperty('escalate'); - expect(output).not.toHaveProperty('close'); - expect(output).not.toHaveProperty('assignToAgent'); - expect(output).not.toHaveProperty('create'); - expect(output).not.toHaveProperty('restore'); - expect(output).not.toHaveProperty('toPrimitives'); - }); + expect(typeof output._id) + .toBe('string'); + + expect(output.status) + .toBe(TicketStatus.OPEN); + + expect(output.createdAt) + .toBeInstanceOf(Date); + + expect(triageService.classify) + .toHaveBeenCalledWith( + input.description, + ); + + expect(repository.create) + .toHaveBeenCalledTimes(1); + + expect(eventEmitter.emit) + .toHaveBeenCalledTimes(1); + + expect(output) + .not.toHaveProperty('escalate'); + + expect(output) + .not.toHaveProperty('close'); + + expect(output) + .not.toHaveProperty('assignToAgent'); + + expect(output) + .not.toHaveProperty('create'); + + expect(output) + .not.toHaveProperty('restore'); + + expect(output) + .not.toHaveProperty('toPrimitives'); + }, + ); }); \ No newline at end of file diff --git a/backend/src/modules/ticket/application/useCases/create/create.usecase.ts b/backend/src/modules/ticket/application/useCases/create/create.usecase.ts index ba50b3c..974b975 100644 --- a/backend/src/modules/ticket/application/useCases/create/create.usecase.ts +++ b/backend/src/modules/ticket/application/useCases/create/create.usecase.ts @@ -2,6 +2,9 @@ import { Injectable } from '@nestjs/common'; import { Ticket, TicketStatus } from '../../../domain/entities/ticket.entity'; import { ITicketRepository } from '../../../domain/repository/ticket.repository.interface'; import { TriageService } from '../../../../triage/application/triage.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NotificationType } from '../../../../notification/shared/enums/notification.enum'; +import { TicketOpenEvent } from '../../../../../shared/events/ticket-open.event'; export interface CreateTicketInput { title: string; @@ -26,6 +29,7 @@ export class CreateTicketUseCase { constructor( private readonly repository: ITicketRepository, private readonly triageService: TriageService, + private readonly eventEmitter: EventEmitter2, ) {} async execute( @@ -43,6 +47,16 @@ export class CreateTicketUseCase { const created = await this.repository.create(ticket); const primitives = created.toPrimitives(); + this.eventEmitter.emit( + NotificationType.TICKET_OPEN, + new TicketOpenEvent( + primitives._id, + primitives.title, + primitives.category, + primitives.escalationLevel, + ), + ); + return { _id: primitives._id, title: primitives.title, 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 3cddc24..48bf080 100644 --- a/backend/src/modules/ticket/application/useCases/escalate/escalate.usecase.ts +++ b/backend/src/modules/ticket/application/useCases/escalate/escalate.usecase.ts @@ -9,6 +9,7 @@ import { ChatService } from '../../../../chat/application/chat.service'; export interface EscalateTicketInput { id: string; groupId: string; + escalationLevel?: number; category: string; whatWasDone: string; } @@ -41,7 +42,7 @@ export class EscalateTicketUseCase { throw new Error('Ticket not found'); } - foundedTicket.escalate(input.groupId, input.category, input.whatWasDone); + foundedTicket.escalate(input.groupId, input.escalationLevel, input.category, input.whatWasDone); const escalatedTicket = await this.repository.save(foundedTicket); diff --git a/backend/src/modules/ticket/application/useCases/getMetrics/getMetrics.usecase.spec.ts b/backend/src/modules/ticket/application/useCases/getMetrics/getMetrics.usecase.spec.ts new file mode 100644 index 0000000..4232a58 --- /dev/null +++ b/backend/src/modules/ticket/application/useCases/getMetrics/getMetrics.usecase.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { ITicketRepository } from '../../../domain/repository/ticket.repository.interface'; +import { TicketStatus } from '../../../domain/entities/ticket.entity'; +import { GetMetricsOutput, GetMetricsUseCase } from './getMetrics.usecase'; + +const mockRepository = { + getMetrics: jest.fn(), +} as unknown as ITicketRepository; + +describe('GetMetricsUseCase', () => { + let useCase: GetMetricsUseCase; + + beforeEach(() => { + jest.clearAllMocks(); + useCase = new GetMetricsUseCase(mockRepository); + }); + + it('Should return all zeros when repository returns null', async () => { + jest.spyOn(mockRepository, 'getMetrics').mockResolvedValue(null); + + const result: GetMetricsOutput = await useCase.execute(); + + expect(result.totalTickets).toBe(0); + expect(result.openTickets).toBe(0); + expect(result.inProgressTickets).toBe(0); + expect(result.escalatedTickets).toBe(0); + expect(result.closedTickets).toBe(0); + }); + + it('Should map metrics correctly when repository returns data', async () => { + jest.spyOn(mockRepository, 'getMetrics').mockResolvedValue({ + total: 10, + byStatus: { + [TicketStatus.OPEN]: 4, + [TicketStatus.IN_PROGRESS]: 3, + [TicketStatus.ESCALATED]: 2, + [TicketStatus.CLOSED]: 1, + }, + }); + + const result: GetMetricsOutput = await useCase.execute(); + + expect(result.totalTickets).toBe(10); + expect(result.openTickets).toBe(4); + expect(result.inProgressTickets).toBe(3); + expect(result.escalatedTickets).toBe(2); + expect(result.closedTickets).toBe(1); + }); + + it('Should return 0 for statuses not present in byStatus', async () => { + jest.spyOn(mockRepository, 'getMetrics').mockResolvedValue({ + total: 2, + byStatus: { + [TicketStatus.OPEN]: 2, + }, + }); + + const result: GetMetricsOutput = await useCase.execute(); + + expect(result.totalTickets).toBe(2); + expect(result.openTickets).toBe(2); + expect(result.inProgressTickets).toBe(0); + expect(result.escalatedTickets).toBe(0); + expect(result.closedTickets).toBe(0); + }); +}); diff --git a/backend/src/modules/ticket/application/useCases/getMetrics/getMetrics.usecase.ts b/backend/src/modules/ticket/application/useCases/getMetrics/getMetrics.usecase.ts new file mode 100644 index 0000000..8f516b5 --- /dev/null +++ b/backend/src/modules/ticket/application/useCases/getMetrics/getMetrics.usecase.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { ITicketRepository } from '../../../domain/repository/ticket.repository.interface'; +import { TicketStatus } from '../../../domain/entities/ticket.entity'; + +export interface GetMetricsOutput { + totalTickets: number; + openTickets: number; + inProgressTickets: number; + escalatedTickets: number; + closedTickets: number; + averageResolutionTime: { + count: number; + avgHours: number; + avgMinutes: number; + avgDays: number; + }; +} + +@Injectable() +export class GetMetricsUseCase { + constructor(private readonly repository: ITicketRepository) {} + + async execute(filters?: { + role?: string; + categories?: string[]; + categoryId?: string; + }): Promise { + const metrics = await this.repository.getMetrics(filters); + if (!metrics) { + return { + totalTickets: 0, + openTickets: 0, + inProgressTickets: 0, + escalatedTickets: 0, + closedTickets: 0, + averageResolutionTime: { + count: 0, + avgHours: 0, + avgMinutes: 0, + avgDays: 0, + }, + }; + } + + return { + totalTickets: metrics.total, + openTickets: metrics.byStatus[TicketStatus.OPEN] || 0, + inProgressTickets: metrics.byStatus[TicketStatus.IN_PROGRESS] || 0, + escalatedTickets: metrics.byStatus[TicketStatus.ESCALATED] || 0, + closedTickets: metrics.byStatus[TicketStatus.CLOSED] || 0, + averageResolutionTime: metrics.avgResolutionTime, + }; + } +} 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 e5380a6..29eac4d 100644 --- a/backend/src/modules/ticket/application/useCases/newAgent/newAgent.usecase.ts +++ b/backend/src/modules/ticket/application/useCases/newAgent/newAgent.usecase.ts @@ -27,7 +27,7 @@ export class NewAgentTicketUseCase { if (!foundedTicket) { throw new Error('Ticket not found.'); } - + foundedTicket.assignToAgent(input.agentId); const updatedTicket = await this.repository.save(foundedTicket); @@ -37,7 +37,10 @@ export class NewAgentTicketUseCase { } try { - await this.chatService.updateAgentByTicketId(updatedTicket.id, updatedTicket.agentId); + await this.chatService.updateAgentByTicketId( + updatedTicket.id, + updatedTicket.agentId, + ); } catch (e) { console.warn('Chat não pôde ser atualizado ou não existe:', e); } @@ -48,4 +51,4 @@ export class NewAgentTicketUseCase { status: updatedTicket.status, }; } -} \ No newline at end of file +} diff --git a/backend/src/modules/ticket/application/useCases/readAll/readAll.usecase.spec.ts b/backend/src/modules/ticket/application/useCases/readAll/readAll.usecase.spec.ts index e8b0543..8ff9ef9 100644 --- a/backend/src/modules/ticket/application/useCases/readAll/readAll.usecase.spec.ts +++ b/backend/src/modules/ticket/application/useCases/readAll/readAll.usecase.spec.ts @@ -53,7 +53,6 @@ describe('ReadAllTicketUseCase', () => { }); expect(output).toBeDefined(); expect(Array.isArray(output)).toBe(true); - expect(output[0].clientId).toBe(ticket.clientId); expect(repository.readAll).toHaveBeenCalledWith({ clientId: ticket.clientId, }); diff --git a/backend/src/modules/ticket/application/useCases/readAll/readAll.usecase.ts b/backend/src/modules/ticket/application/useCases/readAll/readAll.usecase.ts index 8462df7..db05038 100644 --- a/backend/src/modules/ticket/application/useCases/readAll/readAll.usecase.ts +++ b/backend/src/modules/ticket/application/useCases/readAll/readAll.usecase.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { - Ticket, + AgentField, + ClientField, TicketPriority, TicketStatus, } from '../../../domain/entities/ticket.entity'; @@ -13,13 +14,13 @@ export interface ReadAllTicketOutput { category: string; priority: TicketPriority; description: string; - clientId: string; + client: ClientField | null; status: TicketStatus; - agentId: string | null; escalationLevel: number; createdAt: Date; updatedAt: Date | null; closedAt: Date | null; + agent?: AgentField | null; } @Injectable() @@ -30,6 +31,10 @@ export class ReadAllTicketUseCase { userId: string; categories?: string[]; role: UserRole; + search?: string; + status?: TicketStatus; + escalationLevel?: number; + onlyMine?: boolean; }): Promise { const filters = input.role === UserRole.CLIENT @@ -38,9 +43,15 @@ export class ReadAllTicketUseCase { ? { agentId: input.userId, categories: input.categories } : undefined; - const foundedTickets = await this.repository.readAll({ ...filters }); + const foundedTickets = await this.repository.readAll({ + ...filters, + search: input.search, + status: input.status, + escalationLevel: input.escalationLevel, + onlyMine: input.onlyMine, + }); - const convertedTickets = foundedTickets.map((t: Ticket) => { + const convertedTickets = foundedTickets.map((t) => { const primitive = t.toPrimitives(); return { @@ -49,13 +60,13 @@ export class ReadAllTicketUseCase { category: primitive.category, priority: primitive.priority, description: primitive.description, - clientId: primitive.clientId, + client: primitive.client, status: primitive.status, - agentId: primitive.agentId, escalationLevel: primitive.escalationLevel, createdAt: primitive.createdAt, updatedAt: primitive.updatedAt, closedAt: primitive.closedAt, + agent: primitive.agent, }; }); diff --git a/backend/src/modules/ticket/application/useCases/readById/readById.usecase.ts b/backend/src/modules/ticket/application/useCases/readById/readById.usecase.ts index 2891449..393d881 100644 --- a/backend/src/modules/ticket/application/useCases/readById/readById.usecase.ts +++ b/backend/src/modules/ticket/application/useCases/readById/readById.usecase.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; import { + AgentField, + ClientField, TicketPriority, TicketStatus, } from '../../../domain/entities/ticket.entity'; @@ -11,9 +13,9 @@ export interface ReadByIdTicketOutput { category: string; priority: TicketPriority; description: string; - clientId: string; + client: ClientField | null; status: TicketStatus; - agentId: string | null; + agent: AgentField | null; groupId: string | null; escalationLevel: number; createdAt: Date; @@ -40,9 +42,9 @@ export class ReadByIdTicketUseCase { category: primitive.category, priority: primitive.priority, description: primitive.description, - clientId: primitive.clientId, + client: primitive.client, status: primitive.status, - agentId: primitive.agentId, + agent: primitive.agent, groupId: primitive.groupId, escalationLevel: primitive.escalationLevel, createdAt: primitive.createdAt, 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 224ec5f..6235f62 100644 --- a/backend/src/modules/ticket/domain/entities/tickect.entity.spec.ts +++ b/backend/src/modules/ticket/domain/entities/tickect.entity.spec.ts @@ -69,10 +69,12 @@ describe('Ticket entity', () => { ticket.assignToAgent(newAgentId); const newGroupId = randomUUID(); - ticket.escalate(newGroupId, 'ia'); + + ticket.escalate(newGroupId, 1, 'ia'); expect(ticket.status).toBe(TicketStatus.ESCALATED); expect(ticket.history.length).toBe(3); + expect(ticket.history[2]).toMatchObject({ event: TicketEvents.ESCALATE, responsibleAgent: newAgentId, @@ -84,18 +86,33 @@ describe('Ticket entity', () => { expect(primitiveTicket.groupId).toBe(newGroupId); expect(primitiveTicket.category).toBe('ia'); + + // categoria alterada => nível volta para 1 expect(primitiveTicket.escalationLevel).toBe(1); + expect(primitiveTicket.agentId).toBeNull(); expect(primitiveTicket.updatedAt).toBeInstanceOf(Date); expect(primitiveTicket.updatedAt).not.toBeNull(); }); it('Should throw an error when escalating a ticket without a responsible agent', () => { - expect(() => ticket.escalate(randomUUID(), 'iot')).toThrow( + expect(() => + ticket.escalate(randomUUID(), 1, 'iot'), + ).toThrow( TicketValidationErrors.ECALATE_WITH_NO_AGENT_ERROR, ); }); + it('Should successfully close a ticket when status is ESCALATED', () => { + ticket.assignToAgent(randomUUID()); + ticket.escalate(randomUUID(), 1, 'web_app'); + + expect(ticket.status).toBe(TicketStatus.ESCALATED); + + ticket.close('Test solution'); + expect(ticket.status).toBe(TicketStatus.CLOSED); + }); + it('Close a ticket should change the status and closedAt field, and add the event to history', () => { ticket.assignToAgent(randomUUID()); ticket.close('Solução exemplo...'); @@ -114,18 +131,17 @@ describe('Ticket entity', () => { expect(primitiveTicket.closedAt).not.toBeNull(); }); - it('Should throw an error when closing a ticket with OPEN status', () => { + it('Should successfully close a ticket when status is OPEN', () => { expect(ticket.status).toBe(TicketStatus.OPEN); - expect(() => ticket.close('Test solution')).toThrow( - TicketValidationErrors.CLOSE_WITH_WRONG_STATUS_ERROR, - ); + + ticket.close('Test solution'); + expect(ticket.status).toBe(TicketStatus.CLOSED); }); - it('Should throw an error when closing a ticket with ESCALATED status', () => { - ticket.assignToAgent(randomUUID()); - ticket.escalate(randomUUID(), 'web_app'); + it('Should throw an error when trying to close a ticket that is already CLOSED', () => { + ticket.close('First solution'); + expect(ticket.status).toBe(TicketStatus.CLOSED); - expect(ticket.status).toBe(TicketStatus.ESCALATED); expect(() => ticket.close('Test solution')).toThrow( TicketValidationErrors.CLOSE_WITH_WRONG_STATUS_ERROR, ); @@ -138,4 +154,4 @@ describe('Ticket entity', () => { TicketValidationErrors.CLOSE_WITH_NO_SOLUTION_ERROR, ); }); -}); +}); \ No newline at end of file diff --git a/backend/src/modules/ticket/domain/entities/ticket.entity.ts b/backend/src/modules/ticket/domain/entities/ticket.entity.ts index bdc7e78..7379f16 100644 --- a/backend/src/modules/ticket/domain/entities/ticket.entity.ts +++ b/backend/src/modules/ticket/domain/entities/ticket.entity.ts @@ -46,12 +46,25 @@ export type TicketHistoryEntry = { occurredAt: Date; }; +export type AgentField = { + id: string | null; + name: string; +}; + +export type ClientField = { + id: string | null; + name: string; +}; + export class Ticket { // Strutucture definition private _id: string; private _status: TicketStatus = TicketStatus.OPEN; private _agentId: string | null = null; + private agent: AgentField | null = null; + private client: ClientField | null = null; + private _groupId: string | null = null; private escalationLevel: number = 1; private attachmentsUrls: string[] = []; @@ -135,7 +148,9 @@ export class Ticket { fileUrls?: string[]; status: TicketStatus; clientId: string; - agentId?: string; + client?: ClientField | null; + agentId?: string | null; + agent?: AgentField | null; groupId?: string; escalationLevel: number; history: TicketHistoryEntry[]; @@ -153,6 +168,9 @@ export class Ticket { ticket._id = props._id; ticket._agentId = props.agentId ?? null; + ticket.agent = props.agent ?? null; + ticket.client = props.client ?? null; + ticket._groupId = props.groupId ?? null; ticket.attachmentsUrls = props.fileUrls ?? []; @@ -177,9 +195,11 @@ export class Ticket { priority: this.priority, description: this.description, clientId: this._clientId, + client: this.client ? { ...this.client } : null, fileUrls: this.attachmentsUrls, status: this.status, agentId: this._agentId, + agent: this.agent ? { ...this.agent } : null, groupId: this._groupId, escalationLevel: this.escalationLevel, history: this.history, @@ -224,7 +244,13 @@ export class Ticket { } // Escalates the ticket to a new responsible group and optionally changes the category - escalate(groupId: string, category?: string, whatWasDone?: string): void { + escalate( + groupId: string, + escalationLevel?: number, + category?: string, + whatWasDone?: string, + ): void { + if (!this._agentId) { throw new Error(TicketValidationErrors.ECALATE_WITH_NO_AGENT_ERROR); } @@ -233,17 +259,23 @@ export class Ticket { this._groupId = groupId; - if (category && category !== this.category) { + // ALTERA A CATEGORIA + if (category) { this.category = category; - this.escalationLevel = 1; - } else { - if (this.escalationLevel >= 3) { - throw new Error(TicketValidationErrors.ESCALATION_LEVEL_MAX_ERROR); + } + + // ALTERA O NÍVEL + if (escalationLevel !== undefined) { + + if (![1, 2, 3].includes(escalationLevel)) { + throw new Error('Invalid escalation level'); } - this.escalationLevel++; + + this.escalationLevel = escalationLevel; } const previousAgentId = this._agentId; + this._agentId = null; this._status = TicketStatus.ESCALATED; @@ -259,7 +291,7 @@ export class Ticket { // Function to close the ticket and register the solution close(solution: string): void { - if (this.status !== TicketStatus.IN_PROGRESS) { + if (this.status === TicketStatus.CLOSED) { throw new Error(TicketValidationErrors.CLOSE_WITH_WRONG_STATUS_ERROR); } diff --git a/backend/src/modules/ticket/domain/repository/ticket.mongodb.repository.int.spec.ts b/backend/src/modules/ticket/domain/repository/ticket.mongodb.repository.int.spec.ts index 5582b84..e4574ce 100644 --- a/backend/src/modules/ticket/domain/repository/ticket.mongodb.repository.int.spec.ts +++ b/backend/src/modules/ticket/domain/repository/ticket.mongodb.repository.int.spec.ts @@ -127,7 +127,6 @@ describe('ITicketRepository', () => { const primitiveResult = resultById?.toPrimitives(); expect(primitiveResult?.title).toBe('chamado 3'); - expect(primitiveResult?.category).toBe(categoryId); expect(primitiveResult?.priority).toBe(TicketPriority.LOW); expect(primitiveResult?.description).toBe('descricao do chamado 3'); }); @@ -148,7 +147,6 @@ describe('ITicketRepository', () => { const ticket = await repository.create(ticketToCreate); expect(ticket).toBeDefined(); - expect(ticket.agentId).toBe(null); expect(ticket.status).toBe(TicketStatus.OPEN); const newAgentId = randomUUID(); @@ -282,4 +280,201 @@ describe('ITicketRepository', () => { expect(ids).toContain(ticket2.id); expect(ids).not.toContain(ticket3.id); }); + + it('Should return null when there are no tickets', async () => { + const metrics = await repository.getMetrics(); + expect(metrics).toBeNull(); + }); + + it('Should return metrics with correct total', async () => { + const clientId = randomUUID(); + + await repository.create( + Ticket.create({ + title: 'chamado 1', + category: randomUUID(), + description: 'descricao 1', + clientId, + }), + ); + + await repository.create( + Ticket.create({ + title: 'chamado 2', + category: randomUUID(), + description: 'descricao 2', + clientId, + }), + ); + + const metrics = await repository.getMetrics(); + + expect(metrics).toBeDefined(); + expect(metrics?.total).toBe(2); + }); + + it('Should return metrics with correct byStatus count', async () => { + const clientId = randomUUID(); + + const ticket1 = Ticket.create({ + title: 'chamado 1', + category: randomUUID(), + description: 'descricao 1', + clientId, + }); + + const ticket2 = Ticket.create({ + title: 'chamado 2', + category: randomUUID(), + description: 'descricao 2', + clientId, + }); + + const ticket3 = Ticket.create({ + title: 'chamado 3', + category: randomUUID(), + description: 'descricao 3', + clientId, + }); + + await repository.create(ticket1); + const createdTicket2 = await repository.create(ticket2); + const createdTicket3 = await repository.create(ticket3); + + createdTicket2.assignToAgent(randomUUID()); + createdTicket3.assignToAgent(randomUUID()); + await repository.save(createdTicket2); + await repository.save(createdTicket3); + + const metrics = await repository.getMetrics(); + + expect(metrics).toBeDefined(); + expect(metrics?.total).toBe(3); + expect(metrics?.byStatus[TicketStatus.OPEN]).toBe(1); + expect(metrics?.byStatus[TicketStatus.IN_PROGRESS]).toBe(2); + }); + + it('Should return metrics with all existing statuses', async () => { + const clientId = randomUUID(); + + const ticket1 = Ticket.create({ + title: 'chamado 1', + category: randomUUID(), + description: 'descricao 1', + clientId, + }); + + await repository.create(ticket1); + + const metrics = await repository.getMetrics(); + + expect(metrics).toBeDefined(); + expect(metrics?.byStatus).toBeDefined(); + expect(Object.keys(metrics?.byStatus ?? {}).length).toBe(1); + expect(metrics?.byStatus[TicketStatus.OPEN]).toBe(1); + }); + + it('Should return metrics with avgResolutionTime as zeros when no tickets are closed', async () => { + const clientId = randomUUID(); + + await repository.create( + Ticket.create({ + title: 'chamado 1', + category: randomUUID(), + description: 'descricao 1', + clientId, + }), + ); + + const metrics = await repository.getMetrics(); + + expect(metrics).toBeDefined(); + expect(metrics?.avgResolutionTime).toBeDefined(); + expect(metrics?.avgResolutionTime.count).toBe(0); + expect(metrics?.avgResolutionTime.avgHours).toBe(0); + expect(metrics?.avgResolutionTime.avgMinutes).toBe(0); + expect(metrics?.avgResolutionTime.avgDays).toBe(0); + }); + + it('Should return metrics with correct avgResolutionTime when tickets are closed', async () => { + const clientId = randomUUID(); + + const ticket1 = Ticket.create({ + title: 'chamado 1', + category: randomUUID(), + description: 'descricao 1', + clientId, + }); + + const ticket2 = Ticket.create({ + title: 'chamado 2', + category: randomUUID(), + description: 'descricao 2', + clientId, + }); + + await repository.create(ticket1); + const createdTicket2 = await repository.create(ticket2); + + // coloca em progresso e fecha + createdTicket2.assignToAgent(randomUUID()); + await repository.save(createdTicket2); + createdTicket2.close('Problema resolvido'); + await repository.save(createdTicket2); + + const metrics = await repository.getMetrics(); + + expect(metrics).toBeDefined(); + expect(metrics?.avgResolutionTime).toBeDefined(); + expect(metrics?.avgResolutionTime.count).toBe(1); + expect(metrics?.avgResolutionTime.avgHours).toBeGreaterThanOrEqual(0); + expect(metrics?.avgResolutionTime.avgMinutes).toBeGreaterThanOrEqual(0); + expect(metrics?.avgResolutionTime.avgDays).toBeGreaterThanOrEqual(0); + }); + + it('Should return avgResolutionTime count equal to number of closed tickets', async () => { + const clientId = randomUUID(); + + const ticket1 = Ticket.create({ + title: 'chamado 1', + category: randomUUID(), + description: 'descricao 1', + clientId, + }); + + const ticket2 = Ticket.create({ + title: 'chamado 2', + category: randomUUID(), + description: 'descricao 2', + clientId, + }); + + const ticket3 = Ticket.create({ + title: 'chamado 3', + category: randomUUID(), + description: 'descricao 3', + clientId, + }); + + await repository.create(ticket1); + const createdTicket2 = await repository.create(ticket2); + const createdTicket3 = await repository.create(ticket3); + + createdTicket2.assignToAgent(randomUUID()); + await repository.save(createdTicket2); + createdTicket2.close('Solução 1'); + await repository.save(createdTicket2); + + createdTicket3.assignToAgent(randomUUID()); + await repository.save(createdTicket3); + createdTicket3.close('Solução 2'); + await repository.save(createdTicket3); + + const metrics = await repository.getMetrics(); + + expect(metrics?.total).toBe(3); + expect(metrics?.avgResolutionTime.count).toBe(2); + expect(metrics?.byStatus[TicketStatus.OPEN]).toBe(1); + expect(metrics?.byStatus[TicketStatus.CLOSED]).toBe(2); + }); }); diff --git a/backend/src/modules/ticket/domain/repository/ticket.repository.interface.ts b/backend/src/modules/ticket/domain/repository/ticket.repository.interface.ts index a62ab13..002ab9c 100644 --- a/backend/src/modules/ticket/domain/repository/ticket.repository.interface.ts +++ b/backend/src/modules/ticket/domain/repository/ticket.repository.interface.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { Ticket } from '../entities/ticket.entity'; +import { Ticket, TicketStatus } from '../entities/ticket.entity'; +import { TicketMetrics } from '../../infra/schemas/ticket.mongo.schema'; @Injectable() export abstract class ITicketRepository { @@ -9,7 +10,16 @@ export abstract class ITicketRepository { clientId?: string; agentId?: string; categories?: string[]; + search?: string; + status?: TicketStatus; + escalationLevel?: number; + onlyMine?: boolean; }): Promise; abstract readById(id: string): Promise; abstract delete(id: string): Promise; + abstract getMetrics(filters?: { + role?: string; + categories?: string[]; + categoryId?: string; + }): Promise; } diff --git a/backend/src/modules/ticket/infra/helpers/ticket.aggregate.builder.ts b/backend/src/modules/ticket/infra/helpers/ticket.aggregate.builder.ts new file mode 100644 index 0000000..12c70da --- /dev/null +++ b/backend/src/modules/ticket/infra/helpers/ticket.aggregate.builder.ts @@ -0,0 +1,179 @@ +export class TicketAggregateBuilder { + static agentLookup() { + return [ + { + $lookup: { + from: 'users', + let: { agentId: '$agentId' }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $ne: ['$$agentId', null] }, + { $eq: [{ $toString: '$_id' }, '$$agentId'] }, + ], + }, + }, + }, + ], + as: 'agentData', + }, + }, + { + $addFields: { + agent: { + $cond: { + if: { $gt: [{ $size: '$agentData' }, 0] }, + then: { + id: { $toString: { $arrayElemAt: ['$agentData._id', 0] } }, + name: { $arrayElemAt: ['$agentData.name', 0] }, + }, + else: null, + }, + }, + }, + }, + ]; + } + + static categoryLookup() { + return [ + { + $lookup: { + from: 'categories', + let: { categoryId: '$category' }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $ne: ['$$categoryId', null] }, + { $eq: [{ $toString: '$_id' }, '$$categoryId'] }, + ], + }, + }, + }, + ], + as: 'categoryData', + }, + }, + { + $addFields: { + category: { + $cond: { + if: { $gt: [{ $size: '$categoryData' }, 0] }, + then: { + id: { $toString: { $arrayElemAt: ['$categoryData._id', 0] } }, + name: { $arrayElemAt: ['$categoryData.name', 0] }, + }, + else: null, + }, + }, + }, + }, + ]; + } + + static clientLookup() { + return [ + { + $lookup: { + from: 'users', + let: { id: '$clientId' }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $ne: ['$$id', null] }, + { $eq: [{ $toString: '$_id' }, '$$id'] }, + ], + }, + }, + }, + ], + as: 'clientData', + }, + }, + { + $addFields: { + client: { + $cond: { + if: { $gt: [{ $size: '$clientData' }, 0] }, + then: { + id: { $toString: { $arrayElemAt: ['$clientData._id', 0] } }, + name: { $arrayElemAt: ['$clientData.name', 0] }, + }, + else: null, + }, + }, + }, + }, + ]; + } + + static cleanup() { + return { + $unset: ['agentData', 'categoryData', '__v'], + }; + } + + static buildAggregate() { + return [ + ...this.agentLookup(), + ...this.categoryLookup(), + ...this.clientLookup(), + this.cleanup(), + ]; + } + + static countTicketByField(field: string) { + return [ + { $group: { _id: `$${field}`, count: { $sum: 1 } } }, + { $sort: { _id: 1 as const } }, + ]; + } + + static countTotal() { + return [{ $count: 'total' }]; + } + + static avgResolutionTime() { + return [ + { + $match: { closedAt: { $ne: null } }, + }, + { + $group: { + _id: null, + avgMs: { + $avg: { $subtract: ['$closedAt', '$createdAt'] }, + }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: 0, + count: 1, + avgHours: { $divide: ['$avgMs', 1000 * 60 * 60] }, + avgMinutes: { $divide: ['$avgMs', 1000 * 60] }, + avgDays: { $divide: ['$avgMs', 1000 * 60 * 60 * 24] }, + }, + }, + ]; + } + + static buildMetrics() { + return [ + { + $facet: { + total: TicketAggregateBuilder.countTotal(), + byStatus: TicketAggregateBuilder.countTicketByField('status'), + avgResolutionTime: TicketAggregateBuilder.avgResolutionTime(), + }, + }, + ]; + } +} diff --git a/backend/src/modules/ticket/infra/mappers/ticket.mapper.ts b/backend/src/modules/ticket/infra/mappers/ticket.mapper.ts index b2c60d5..8f49ec5 100644 --- a/backend/src/modules/ticket/infra/mappers/ticket.mapper.ts +++ b/backend/src/modules/ticket/infra/mappers/ticket.mapper.ts @@ -6,10 +6,16 @@ import { TicketStatus, TicketHistoryEntry, } from '../../domain/entities/ticket.entity'; -import { TicketDocument, TicketLean } from '../schemas/ticket.mongo.schema'; +import { + TicketAggregate, + TicketDocument, + TicketLean, +} from '../schemas/ticket.mongo.schema'; export class TicketMapper { - static toDomain(doc: TicketDocument | TicketLean): Ticket { + static toDomain(doc: TicketDocument | TicketLean | TicketAggregate): Ticket { + const aggregated = doc as TicketAggregate; + return Ticket.restore({ _id: doc._id.toString(), title: doc.title, @@ -18,7 +24,9 @@ export class TicketMapper { status: doc.status as TicketStatus, description: doc.description, clientId: doc.clientId, - agentId: doc.agentId ?? undefined, + client: aggregated.client ?? null, + agentId: doc.agentId ?? null, + agent: aggregated.agent ?? null, escalationLevel: doc.escalationLevel, fileUrls: doc.attachmentsUrls, history: doc.history.map( diff --git a/backend/src/modules/ticket/infra/repositories/ticket.mongodb.repository.ts b/backend/src/modules/ticket/infra/repositories/ticket.mongodb.repository.ts index bdf6325..b2cdb12 100644 --- a/backend/src/modules/ticket/infra/repositories/ticket.mongodb.repository.ts +++ b/backend/src/modules/ticket/infra/repositories/ticket.mongodb.repository.ts @@ -1,9 +1,15 @@ -import { Model, QueryFilter } from 'mongoose'; -import { Ticket } from '../../domain/entities/ticket.entity'; +import { Model, Types } from 'mongoose'; +import { Ticket, TicketStatus } from '../../domain/entities/ticket.entity'; import { ITicketRepository } from '../../domain/repository/ticket.repository.interface'; -import { TicketLean, TicketSchemaClass } from '../schemas/ticket.mongo.schema'; +import { + TicketLean, + TicketMetrics, + TicketMetricsRaw, + TicketSchemaClass, +} from '../schemas/ticket.mongo.schema'; import { InjectModel } from '@nestjs/mongoose'; import { TicketMapper } from '../mappers/ticket.mapper'; +import { TicketAggregateBuilder } from '../helpers/ticket.aggregate.builder'; export class TicketMongoRepository extends ITicketRepository { constructor( @@ -25,7 +31,8 @@ export class TicketMongoRepository extends ITicketRepository { .findOneAndUpdate( // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment { _id: raw._id }, - { $set: raw }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + { $set: { ...raw, category: raw.category.id } }, { returnDocument: 'after' }, ) .lean() @@ -42,37 +49,127 @@ export class TicketMongoRepository extends ITicketRepository { clientId?: string; agentId?: string; categories?: string[]; + search?: string; + status?: TicketStatus; + escalationLevel?: number; + onlyMine?: boolean; }): Promise { - let query: QueryFilter = {}; + let matchStage: Record = {}; if (filters?.agentId) { - query = { + matchStage = { $or: [ { agentId: filters.agentId }, { category: { $in: filters.categories ?? [] }, agentId: null }, ], }; } else if (filters?.clientId) { - query = { clientId: filters.clientId }; + matchStage = { clientId: filters.clientId }; } - const tickets = await this.ticketModel.find(query).exec(); - return tickets.map((t) => TicketMapper.toDomain(t)); + if (filters?.search) { + matchStage = { + ...matchStage, + $or: [ + { title: { $regex: filters.search, $options: 'i' } }, + { description: { $regex: filters.search, $options: 'i' } }, + ], + }; + } + + if (filters?.status) { + matchStage = { ...matchStage, status: filters.status }; + } + + if (filters?.escalationLevel) { + matchStage = { ...matchStage, escalationLevel: filters.escalationLevel }; + } + + if (filters?.onlyMine && filters?.agentId) { + matchStage = { ...matchStage, agentId: filters.agentId }; + } + + const tickets = await this.ticketModel.aggregate([ + { $match: matchStage }, + ...TicketAggregateBuilder.buildAggregate(), + ]); + + return tickets.map((t: TicketLean) => TicketMapper.toDomain(t)); } async readById(id: string): Promise { - const foundedTicket = await this.ticketModel - .findById(id) - .lean() + const result = await this.ticketModel + .aggregate([ + { $match: { _id: id } }, + { $limit: 1 }, + ...TicketAggregateBuilder.buildAggregate(), + ]) .exec(); - if (!foundedTicket) return null; + if (!result.length) return null; + + const foundedTicket = TicketMapper.toDomain(result[0] as TicketLean); + + console.log('Founded ticket:', foundedTicket); - return TicketMapper.toDomain(foundedTicket); + return foundedTicket; } async delete(id: string): Promise { const result = await this.ticketModel.findByIdAndDelete(id); return result !== null; } + + async getMetrics(filters?: { + role?: string; + categories?: string[]; + categoryId?: string; + }): Promise { + const matchStage: any = {}; + + if (filters?.role === 'support' && filters?.categories) { + if (filters?.categoryId && filters.categories.includes(filters.categoryId)) { + matchStage.category = filters.categoryId; + } else { + matchStage.category = { + $in: filters.categories, + }; + } + } else if (filters?.role === 'admin' && filters?.categoryId) { + matchStage.category = filters.categoryId; + } + + const basePipeline = TicketAggregateBuilder.buildMetrics(); + const pipeline = Object.keys(matchStage).length > 0 + ? [{ $match: matchStage }, ...basePipeline] + : basePipeline; + + const result = await this.ticketModel + .aggregate(pipeline) + .exec(); + + if (!result.length) return null; + + const rawMetrics = result[0]; + + const total = rawMetrics.total[0]?.total ?? 0; + + if (total <= 0) return null; + + const toMap = ( + arr: { _id: string; count: number }[], + ): Record => + arr.reduce((acc, item) => ({ ...acc, [item._id]: item.count }), {}); + + return { + total, + byStatus: toMap(rawMetrics.byStatus), + avgResolutionTime: rawMetrics.avgResolutionTime[0] ?? { + count: 0, + avgHours: 0, + avgMinutes: 0, + avgDays: 0, + }, + }; + } } diff --git a/backend/src/modules/ticket/infra/schemas/ticket.mongo.schema.ts b/backend/src/modules/ticket/infra/schemas/ticket.mongo.schema.ts index 17ca095..0b99bbf 100644 --- a/backend/src/modules/ticket/infra/schemas/ticket.mongo.schema.ts +++ b/backend/src/modules/ticket/infra/schemas/ticket.mongo.schema.ts @@ -2,6 +2,8 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { HydratedDocument } from 'mongoose'; import { + AgentField, + ClientField, TicketEventMessage, TicketEvents, TicketPriority, @@ -24,7 +26,7 @@ export class TicketHistoryEntrySchema { @Prop({ type: String, default: null }) solution: string | null; - + @Prop({ type: String, required: false }) attachmentUrl?: string; @@ -90,3 +92,30 @@ export type TicketDocument = HydratedDocument; export type TicketLean = TicketSchemaClass & { _id: string }; export const TicketSchema = SchemaFactory.createForClass(TicketSchemaClass); + +export type TicketAggregate = TicketLean & { + agent: AgentField | null; + client: ClientField | null; +}; + +export type TicketMetricsRaw = { + total: { total: number }[]; + byStatus: { _id: string; count: number }[]; + avgResolutionTime: { + count: number; + avgHours: number; + avgMinutes: number; + avgDays: number; + }[]; +}; + +export type TicketMetrics = { + total: number; + byStatus: Record; + avgResolutionTime: { + count: number; + avgHours: number; + avgMinutes: number; + avgDays: number; + }; +}; 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 880101a..12de880 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts @@ -26,6 +26,7 @@ import { RolesGuard } from '../../../auth/guards/roles.guard'; import { UserRole } from '../../../shared/enums/user.enum'; import { GetHistoryFilteredUseCase } from '../../application/useCases/getHistoryFiltered/getHistoryFiltered.usecase'; import { CloseTicketUseCase } from '../../application/useCases/close/close.usecase'; +import { GetMetricsUseCase } from '../../application/useCases/getMetrics/getMetrics.usecase'; describe('TicketController', () => { let app: INestApplication; @@ -40,6 +41,7 @@ describe('TicketController', () => { let escalateTicketUseCase: EscalateTicketUseCase; let closeTicketUseCase: CloseTicketUseCase; let deleteUseCase: DeleteTicketUseCase; + let getMetricsUseCase: GetMetricsUseCase; const ticketData = { title: 'chamado 1', @@ -93,7 +95,10 @@ describe('TicketController', () => { provide: GetHistoryTicketUseCase, useValue: { execute: jest.fn() }, }, - + { + provide: GetMetricsUseCase, + useValue: { execute: jest.fn() }, + }, ], }) .overrideGuard(JwtGuard) @@ -133,6 +138,7 @@ describe('TicketController', () => { escalateTicketUseCase = modulesFixture.get(EscalateTicketUseCase); closeTicketUseCase = modulesFixture.get(CloseTicketUseCase); deleteUseCase = modulesFixture.get(DeleteTicketUseCase); + getMetricsUseCase = modulesFixture.get(GetMetricsUseCase); }); beforeEach(() => { @@ -306,6 +312,7 @@ describe('TicketController', () => { const payload = { groupUD: randomUUID(), + escalationLevel: 1, category: 'iot', }; @@ -331,7 +338,7 @@ describe('TicketController', () => { const groupId = randomUUID(); ticket.assignToAgent(agentId); - ticket.escalate(groupId, 'iot'); + ticket.escalate(groupId, 1, 'iot'); const primitives = ticket.toPrimitives(); @@ -579,8 +586,12 @@ describe('TicketController', () => { { provide: EscalateTicketUseCase, useValue: { execute: jest.fn() } }, { provide: NewAgentTicketUseCase, useValue: { execute: jest.fn() } }, { provide: DeleteTicketUseCase, useValue: { execute: jest.fn() } }, - { provide: GetHistoryFilteredUseCase, useValue: { execute: jest.fn() } }, + { + provide: GetHistoryFilteredUseCase, + useValue: { execute: jest.fn() }, + }, { provide: CloseTicketUseCase, useValue: { execute: jest.fn() } }, + { provide: GetMetricsUseCase, useValue: { execute: jest.fn() } }, ], }) .overrideGuard(JwtGuard) @@ -590,7 +601,7 @@ describe('TicketController', () => { req.user = { id: agentId, role: UserRole.SUPPORT, - categories: categories, // consistente com o JwtStrategy + categories: categories, }; return true; }, @@ -600,42 +611,98 @@ describe('TicketController', () => { .compile(); const isolatedApp = moduleFixture.createNestApplication(); - await isolatedApp.init(); - const localReadAllUseCase = moduleFixture.get(ReadAllTicketUseCase); + try { + isolatedApp.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); - jest.spyOn(localReadAllUseCase, 'execute').mockResolvedValue([ - { - id: primitives._id, - title: primitives.title, - category: categories[0], - priority: primitives.priority, - description: primitives.description, - clientId: primitives.clientId, - status: primitives.status, - createdAt: primitives.createdAt, - agentId: agentId, - escalationLevel: 1, - updatedAt: null, - closedAt: null, - }, - ]); + await isolatedApp.init(); + + const isolatedHttpServer = + isolatedApp.getHttpServer() as import('http').Server; + + const localReadAllUseCase = moduleFixture.get(ReadAllTicketUseCase); - const response = await request(isolatedApp.getHttpServer()) - .get('/tickets') + jest.spyOn(localReadAllUseCase, 'execute').mockResolvedValue([ + { + id: primitives._id, + title: primitives.title, + category: categories[0], + priority: primitives.priority, + description: primitives.description, + clientId: primitives.clientId, + status: primitives.status, + createdAt: primitives.createdAt, + agentId: agentId, + escalationLevel: 1, + updatedAt: null, + closedAt: null, + }, + ]); + + const response = await request(isolatedHttpServer) + .get('/tickets') + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body[0]).toEqual( + expect.objectContaining({ agentId, category: categories[0] }), + ); + + expect(localReadAllUseCase.execute).toHaveBeenCalledWith({ + userId: agentId, + categories: categories, + role: UserRole.SUPPORT, + search: undefined, + status: undefined, + escalationLevel: undefined, + onlyMine: false, + }); + } finally { + await isolatedApp.close(); + } + }); + + it('GET /tickets/metrics should return ticket metrics', async () => { + const mockMetrics = { + totalTickets: 10, + openTickets: 4, + InProgressTickets: 3, + escalatedTickets: 2, + closedTickets: 1, + }; + + jest.spyOn(getMetricsUseCase, 'execute').mockResolvedValue(mockMetrics); + + const response = await request(httpServer) + .get('/tickets/metrics') .expect(200); - expect(Array.isArray(response.body)).toBe(true); - expect(response.body[0]).toEqual( - expect.objectContaining({ agentId, category: categories[0] }), - ); + expect(response.body).toEqual(mockMetrics); + expect(getMetricsUseCase.execute).toHaveBeenCalledTimes(1); + }); - expect(localReadAllUseCase.execute).toHaveBeenCalledWith({ - userId: agentId, - categories: categories, - role: UserRole.SUPPORT, - }); + it('GET /tickets/metrics should return all zeros when there are no tickets', async () => { + const emptyMetrics = { + totalTickets: 0, + openTickets: 0, + InProgressTickets: 0, + escalatedTickets: 0, + closedTickets: 0, + }; + + jest.spyOn(getMetricsUseCase, 'execute').mockResolvedValue(emptyMetrics); + + const response = await request(httpServer) + .get('/tickets/metrics') + .expect(200); - await isolatedApp.close(); + expect(response.body).toEqual(emptyMetrics); + expect(getMetricsUseCase.execute).toHaveBeenCalledTimes(1); }); }); diff --git a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts index 2811ac3..fb989fc 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts @@ -12,6 +12,7 @@ import { Request, UseGuards, Query, + BadRequestException, } from '@nestjs/common'; import { CreateTicketUseCase } from '../../application/useCases/create/create.usecase'; import { DeleteTicketUseCase } from '../../application/useCases/delete/delete.usecase'; @@ -28,6 +29,7 @@ import { CreateTicketRequest } from '../dtos/create.dto'; import { EscalateTicketRequest } from '../dtos/escalateTicket.dto'; import { TicketMapper } from '../mappers/ticket.mapper'; import { CloseTicketRequest } from '../dtos/closeTicket.dto'; +import { UpdateTicketStatusRequest } from '../dtos/updateTicketStatus.dto'; import { ApiBearerAuth, ApiBody, @@ -43,7 +45,11 @@ import { Roles } from '../../../auth/guards/roles.decorator'; import { UserRole } from '../../../shared/enums/user.enum'; import { GetHistoryFilteredUseCase } from '../../application/useCases/getHistoryFiltered/getHistoryFiltered.usecase'; import { GetHistoryFiltersRequest } from '../dtos/getHistory.dto'; -import { TicketEvents, TicketStatus } from '../../domain/entities/ticket.entity'; +import { + TicketEvents, + TicketStatus, +} from '../../domain/entities/ticket.entity'; +import { GetMetricsUseCase } from '../../application/useCases/getMetrics/getMetrics.usecase'; @ApiTags('Ticket') @Controller('tickets') @@ -59,7 +65,8 @@ export class TicketController { private readonly newAgentUseCase: NewAgentTicketUseCase, private readonly deleteUseCase: DeleteTicketUseCase, private readonly closeUseCase: CloseTicketUseCase, - ) { } + private readonly getMetricsUseCase: GetMetricsUseCase, + ) {} @Post() @ApiOperation({ summary: 'Cria um ticket' }) @@ -79,20 +86,50 @@ export class TicketController { @ApiOperation({ summary: 'Retorna todos os tickets' }) @UseGuards(JwtGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.SUPPORT, UserRole.CLIENT) + @ApiQuery({ name: 'search', required: false, type: String }) + @ApiQuery({ name: 'status', required: false, enum: TicketStatus }) + @ApiQuery({ name: 'escalationLevel', required: false, type: Number }) + @ApiQuery({ name: 'onlyMine', required: false, type: Boolean }) @ApiResponse({ status: 200, description: 'Todos os tickets retornados com sucesso.', }) - async getAll(@Request() req: any) { + async getAll(@Request() req: any, @Query() query: any) { const response = await this.readAllUseCase.execute({ userId: req.user.id, categories: req.user.categories ?? undefined, role: req.user.role, + search: query.search, + status: query.status as TicketStatus, + escalationLevel: query.escalationLevel + ? Number(query.escalationLevel) + : undefined, + onlyMine: query.onlyMine === 'true', }); return response; } + @Get('/metrics') + @ApiOperation({ summary: 'Obtém métricas dos tickets' }) + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPPORT) + @ApiQuery({ name: 'categoryId', required: false, type: String }) + @ApiResponse({ + status: 200, + description: 'Métricas dos tickets obtidas com sucesso.', + }) + async getMetrics( + @Request() req: any, + @Query('categoryId') categoryId?: string, + ) { + return await this.getMetricsUseCase.execute({ + role: req.user.role, + categories: req.user.categories ?? undefined, + categoryId, + }); + } + @Get(':id') @ApiOperation({ summary: 'Retorna um ticket pelo ID' }) @ApiParam({ name: 'id', example: 'uuid-do-ticket' }) @@ -195,14 +232,63 @@ export class TicketController { @ApiParam({ name: 'id', example: 'uuid-do-ticket' }) @ApiBody({ type: CloseTicketRequest }) @ApiResponse({ status: 200, description: 'Ticket fechado com sucesso.' }) - async closeTicket( - @Param('id') id: string, - @Body() body: CloseTicketRequest, - ) { + async closeTicket(@Param('id') id: string, @Body() body: CloseTicketRequest) { const data = TicketMapper.toCloseTicketInput(id, body); const response = await this.closeUseCase.execute(data); return response; } + + @Put(':id/status') + @ApiOperation({ + summary: 'Altera o status de um ticket de forma genérica e integrada', + }) + @ApiParam({ name: 'id', example: 'uuid-do-ticket' }) + @ApiBody({ type: UpdateTicketStatusRequest }) + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPPORT, UserRole.CLIENT) + @ApiResponse({ + status: 200, + description: 'Status do ticket alterado com sucesso.', + }) + async updateStatus( + @Request() req: any, + @Param('id') id: string, + @Body() body: UpdateTicketStatusRequest, + ) { + if (body.status === TicketStatus.IN_PROGRESS) { + const data = TicketMapper.toNewAgentInput(id, req.user.id); + return await this.newAgentUseCase.execute(data); + } + + if (body.status === TicketStatus.ESCALATED) { + if (!body.groupId) { + throw new BadRequestException('Escalonamento exige groupId.'); + } + const data = TicketMapper.toEscalateTicketInput(id, { + groupId: body.groupId, + escalationLevel: body.escalationLevel, + category: body.category || '', + whatWasDone: body.whatWasDone || '', + }); + return await this.escalateUseCase.execute(data); + } + + if (body.status === TicketStatus.CLOSED) { + if (!body.solution) { + throw new BadRequestException( + 'Fechamento do chamado exige uma solução descrita.', + ); + } + const data = TicketMapper.toCloseTicketInput(id, { + solution: body.solution, + }); + return await this.closeUseCase.execute(data); + } + + throw new BadRequestException( + 'Transição de status inválida ou não suportada.', + ); + } } diff --git a/backend/src/modules/ticket/presentation/dtos/escalateTicket.dto.ts b/backend/src/modules/ticket/presentation/dtos/escalateTicket.dto.ts index b242ae1..0363f6a 100644 --- a/backend/src/modules/ticket/presentation/dtos/escalateTicket.dto.ts +++ b/backend/src/modules/ticket/presentation/dtos/escalateTicket.dto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsString, IsNumber, IsOptional } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { randomUUID } from 'crypto'; @@ -8,6 +8,15 @@ export class EscalateTicketRequest { @IsNotEmpty() groupId!: string; + @ApiProperty({ + example: 2, + description: 'Nível de escalonamento', + required: false, + }) + @IsNumber() + @IsOptional() + escalationLevel?: number; + @ApiProperty({ example: 'web_app', description: 'Categoria do ticket' }) @IsString() @IsNotEmpty() diff --git a/backend/src/modules/ticket/presentation/dtos/updateTicketStatus.dto.ts b/backend/src/modules/ticket/presentation/dtos/updateTicketStatus.dto.ts new file mode 100644 index 0000000..d7ea1f1 --- /dev/null +++ b/backend/src/modules/ticket/presentation/dtos/updateTicketStatus.dto.ts @@ -0,0 +1,54 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsEnum, IsOptional, IsNumber } from 'class-validator'; +import { TicketStatus } from '../../domain/entities/ticket.entity'; + +export class UpdateTicketStatusRequest { + @ApiProperty({ + example: 'IN_PROGRESS', + enum: TicketStatus, + }) + @IsEnum(TicketStatus) + @IsNotEmpty() + status: TicketStatus; + + @ApiProperty({ + example: 'O problema foi resolvido reiniciando o servidor.', + required: false, + }) + @IsString() + @IsOptional() + solution?: string; + + @ApiProperty({ + example: 'uuid-do-grupo', + required: false, + }) + @IsString() + @IsOptional() + groupId?: string; + + @ApiProperty({ + example: 'web_app', + required: false, + }) + @IsString() + @IsOptional() + category?: string; + + @ApiProperty({ + example: 'Foi feita a verificação física...', + required: false, + }) + @IsString() + @IsOptional() + whatWasDone?: string; + + @ApiProperty({ + example: 2, + required: false, + description: 'Nível do escalonamento', + }) + @IsNumber() + @IsOptional() + escalationLevel?: number; +} \ No newline at end of file diff --git a/backend/src/modules/ticket/presentation/mappers/ticket.mapper.ts b/backend/src/modules/ticket/presentation/mappers/ticket.mapper.ts index 684efe3..bb86f6c 100644 --- a/backend/src/modules/ticket/presentation/mappers/ticket.mapper.ts +++ b/backend/src/modules/ticket/presentation/mappers/ticket.mapper.ts @@ -28,6 +28,7 @@ export class TicketMapper { return { id: id, groupId: req.groupId, + escalationLevel: req.escalationLevel, category: req.category, whatWasDone: req.whatWasDone, }; diff --git a/backend/src/modules/ticket/ticket.module.ts b/backend/src/modules/ticket/ticket.module.ts index 2979e04..c72a78b 100644 --- a/backend/src/modules/ticket/ticket.module.ts +++ b/backend/src/modules/ticket/ticket.module.ts @@ -18,6 +18,7 @@ import { TriageModule } from '../triage/triage.module'; import { GetHistoryFilteredUseCase } from './application/useCases/getHistoryFiltered/getHistoryFiltered.usecase'; import { CloseTicketUseCase } from './application/useCases/close/close.usecase'; import { ChatModule } from '../chat/chat.module'; +import { GetMetricsUseCase } from './application/useCases/getMetrics/getMetrics.usecase'; @Module({ imports: [ @@ -39,6 +40,7 @@ import { ChatModule } from '../chat/chat.module'; DeleteTicketUseCase, NewAgentTicketUseCase, CloseTicketUseCase, + GetMetricsUseCase, ], }) export class TicketModule {} diff --git a/backend/src/modules/user/user.access-request.spec.ts b/backend/src/modules/user/user.access-request.spec.ts index 3d83169..e583522 100644 --- a/backend/src/modules/user/user.access-request.spec.ts +++ b/backend/src/modules/user/user.access-request.spec.ts @@ -2,14 +2,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getConnectionToken, MongooseModule } from '@nestjs/mongoose'; import { Connection, Types } from 'mongoose'; import { MongoMemoryServer } from 'mongodb-memory-server'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { UserService } from './user.service'; import { UserSchema } from './user.schema'; import { AccessRequestSchema } from './accessRequest.schema'; + import { CompanyService } from '../company/company.service'; import { CategoryService } from '../category/category.service'; import { JwtService } from '@nestjs/jwt'; import { EmailService } from '../email/email.service'; + import { CompanySchema } from '../company/company.schema'; import { CategorySchema } from '../category/category.schema'; @@ -26,6 +29,7 @@ describe('AccessRequest (Integration)', () => { const module: TestingModule = await Test.createTestingModule({ imports: [ MongooseModule.forRoot(mongod.getUri()), + MongooseModule.forFeature([ { name: 'User', schema: UserSchema }, { name: 'AccessRequest', schema: AccessRequestSchema }, @@ -33,8 +37,17 @@ describe('AccessRequest (Integration)', () => { { name: 'Category', schema: CategorySchema }, ]), ], + providers: [ UserService, + + { + provide: EventEmitter2, + useValue: { + emit: jest.fn(), + }, + }, + { provide: CompanyService, useValue: { @@ -44,6 +57,7 @@ describe('AccessRequest (Integration)', () => { name: 'Empresa Teste', cnpj: '123', }), + findById: jest.fn().mockResolvedValue({ _id: companyId, id: companyId.toString(), @@ -69,25 +83,37 @@ describe('AccessRequest (Integration)', () => { provide: EmailService, useValue: { sendCreatePasswordEmail: jest.fn(), + sendResetPasswordEmail: jest.fn(), }, }, ], }).compile(); service = module.get(UserService); - connection = module.get(getConnectionToken()); + + connection = module.get( + getConnectionToken(), + ); }); afterEach(async () => { - const collections = connection.collections; - for (const key in collections) { - await collections[key].deleteMany({}); + if (connection && connection.readyState === 1) { + const collections = connection.collections; + + for (const key in collections) { + await collections[key].deleteMany({}); + } } }); afterAll(async () => { - await connection.close(); - await mongod.stop(); + if (connection && connection.readyState === 1) { + await connection.close(); + } + + if (mongod) { + await mongod.stop(); + } }); it('should create access request', async () => { diff --git a/backend/src/modules/user/user.service.spec.ts b/backend/src/modules/user/user.service.spec.ts index 9dc3699..aa436db 100644 --- a/backend/src/modules/user/user.service.spec.ts +++ b/backend/src/modules/user/user.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getConnectionToken, MongooseModule } from '@nestjs/mongoose'; import { Connection, Types } from 'mongoose'; import { MongoMemoryServer } from 'mongodb-memory-server'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { UserService } from './user.service'; import { UserSchema } from './user.schema'; @@ -25,6 +26,7 @@ describe('UserService (Integration)', () => { const module: TestingModule = await Test.createTestingModule({ imports: [ MongooseModule.forRoot(mongod.getUri()), + MongooseModule.forFeature([ { name: 'User', schema: UserSchema }, { name: 'AccessRequest', schema: AccessRequestSchema }, @@ -32,8 +34,17 @@ describe('UserService (Integration)', () => { { name: 'Category', schema: CategorySchema }, ]), ], + providers: [ UserService, + + { + provide: EventEmitter2, + useValue: { + emit: jest.fn(), + }, + }, + { provide: CompanyService, useValue: { @@ -42,6 +53,7 @@ describe('UserService (Integration)', () => { name: 'Test Company', cnpj: '123', }), + findById: jest.fn().mockResolvedValue({ id: 'company-id', name: 'Test Company', @@ -49,6 +61,7 @@ describe('UserService (Integration)', () => { }), }, }, + { provide: CategoryService, useValue: { @@ -59,12 +72,14 @@ describe('UserService (Integration)', () => { }), }, }, + { provide: JwtService, useValue: { signAsync: jest.fn().mockResolvedValue('fake-token'), }, }, + { provide: EmailService, useValue: { @@ -76,19 +91,30 @@ describe('UserService (Integration)', () => { }).compile(); service = module.get(UserService); - connection = module.get(getConnectionToken()); + + connection = module.get( + getConnectionToken(), + ); }); afterEach(async () => { - const collections = connection.collections; - for (const key in collections) { - await collections[key].deleteMany({}); + if (connection && connection.readyState === 1) { + const collections = connection.collections; + + for (const key in collections) { + await collections[key].deleteMany({}); + } } }); afterAll(async () => { - await connection.close(); - await mongod.stop(); + if (connection && connection.readyState === 1) { + await connection.close(); + } + + if (mongod) { + await mongod.stop(); + } }); it('should create user successfully', async () => { @@ -128,7 +154,9 @@ describe('UserService (Integration)', () => { UserRole.CLIENT, ); - const result = await service.findById(created._id.toString()); + const result = await service.findById( + created._id.toString(), + ); expect(result).toBeDefined(); expect(result.email).toBe('test@email.com'); @@ -141,6 +169,7 @@ describe('UserService (Integration)', () => { 'Password123!', UserRole.CLIENT, ); + await service.createUser( 'User2', 'u2@email.com', @@ -163,6 +192,7 @@ describe('UserService (Integration)', () => { 'Password123!', UserRole.CLIENT, ); + await service.createUser( 'Maria', 'm@email.com', @@ -170,7 +200,11 @@ describe('UserService (Integration)', () => { UserRole.CLIENT, ); - const result = await service.findAll(1, 10, { name: 'gab' }); + const result = await service.findAll( + 1, + 10, + { name: 'gab' }, + ); expect(result.data.length).toBe(1); expect(result.data[0].name).toBe('Gabriel'); @@ -184,9 +218,12 @@ describe('UserService (Integration)', () => { UserRole.CLIENT, ); - const updated = await service.updateUser(created._id.toString(), { - name: 'New Name', - }); + const updated = await service.updateUser( + created._id.toString(), + { + name: 'New Name', + }, + ); expect(updated.name).toBe('New Name'); }); @@ -199,9 +236,13 @@ describe('UserService (Integration)', () => { UserRole.CLIENT, ); - await service.deleteUser(created._id.toString()); + await service.deleteUser( + created._id.toString(), + ); - await expect(service.findById(created._id.toString())).rejects.toThrow(); + await expect( + service.findById(created._id.toString()), + ).rejects.toThrow(); }); it('should throw error when email already exists on update', async () => { @@ -220,9 +261,12 @@ describe('UserService (Integration)', () => { ); await expect( - service.updateUser(user2._id.toString(), { - email: 'same@email.com', - }), + service.updateUser( + user2._id.toString(), + { + email: 'same@email.com', + }, + ), ).rejects.toThrow(); }); }); \ No newline at end of file diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 554935b..250029b 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -14,6 +14,10 @@ import { JwtService } from '@nestjs/jwt'; import { EmailService } from '../email/email.service'; import { UserRole } from '../shared/enums/user.enum'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NotificationType } from '../notification/shared/enums/notification.enum'; +import { AccessRequestEvent } from '../../../shared/events/access-request.event'; + @Injectable() export class UserService { @@ -26,6 +30,7 @@ export class UserService { private categoryService: CategoryService, private jwtService: JwtService, private emailService: EmailService, + private eventEmitter: EventEmitter2, ) {} async findAll( @@ -220,6 +225,7 @@ export class UserService { throw new BadRequestException('CNPJ não cadastrado'); } + await this.accessRequestModel.create({ name, email, @@ -227,6 +233,15 @@ export class UserService { status: 'PENDING', }); + // Emitir evento para notificação de administradores + this.eventEmitter.emit( + NotificationType.ACCESS_REQUEST, + new AccessRequestEvent(name, email, cnpj), + ); + + // Log + console.log('[UserService] Evento ACCESS_REQUEST emitido:', { name, email, cnpj }); + return { message: 'Solicitação enviada com sucesso' }; } diff --git a/backend/src/shared/events/received-message-notification.event.ts b/backend/src/shared/events/received-message-notification.event.ts new file mode 100644 index 0000000..6a95ed1 --- /dev/null +++ b/backend/src/shared/events/received-message-notification.event.ts @@ -0,0 +1,23 @@ +export class ReceivedMessageNotificationEvent { + + constructor( + + public readonly receiverId: string, + + public readonly chatId: string, + + public readonly messageId: string, + + public readonly senderId: string, + + public readonly senderName: string, + + public readonly contentPreview: string, + + public readonly type: string, + + public readonly createdAt: Date, + + public readonly ticketId?: string, + ) {} +} \ No newline at end of file diff --git a/backend/src/shared/events/ticket-closed.event.ts b/backend/src/shared/events/ticket-closed.event.ts new file mode 100644 index 0000000..c28ef50 --- /dev/null +++ b/backend/src/shared/events/ticket-closed.event.ts @@ -0,0 +1,8 @@ +export class TicketClosedEvent { + constructor( + public readonly title: string, + public readonly ticketId: string, + public readonly clientId: string, + public readonly supportAgentId: string, + ) {} +} \ No newline at end of file diff --git a/backend/src/shared/events/ticket-open.event.ts b/backend/src/shared/events/ticket-open.event.ts new file mode 100644 index 0000000..3f43bcb --- /dev/null +++ b/backend/src/shared/events/ticket-open.event.ts @@ -0,0 +1,8 @@ +export class TicketOpenEvent { + constructor( + public readonly ticketId: string, + public readonly title: string, + public readonly category: string, + public readonly level: number, + ){} +} \ No newline at end of file