From bfb21b89a51ad3b7cd0d987b1f1155ccd96d2e63 Mon Sep 17 00:00:00 2001 From: Vinicius Elias <95414991+ViniElias@users.noreply.github.com> Date: Wed, 13 May 2026 22:26:28 -0300 Subject: [PATCH 01/12] Fix: filtros de chamados agora associam a categoria ao atendente corretamente (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: filtros de chamados agora associam a categoria ao atendente corretamente * Refact: adição de bloco try-finally * Fix: fechamento incorreto de chaves * Fix: expect com assinatura antiga --- .../useCases/readAll/readAll.usecase.ts | 12 ++- .../repository/ticket.repository.interface.ts | 6 +- .../repositories/ticket.mongodb.repository.ts | 28 +++++- .../controllers/ticket.controller.spec.ts | 93 +++++++++++-------- .../controllers/ticket.controller.ts | 15 ++- 5 files changed, 108 insertions(+), 46 deletions(-) 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..5d84392 100644 --- a/backend/src/modules/ticket/application/useCases/readAll/readAll.usecase.ts +++ b/backend/src/modules/ticket/application/useCases/readAll/readAll.usecase.ts @@ -30,6 +30,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,7 +42,13 @@ 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 primitive = t.toPrimitives(); 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..1420269 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,5 @@ import { Injectable } from '@nestjs/common'; -import { Ticket } from '../entities/ticket.entity'; +import { Ticket, TicketStatus } from '../entities/ticket.entity'; @Injectable() export abstract class ITicketRepository { @@ -9,6 +9,10 @@ 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; 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..a17faa9 100644 --- a/backend/src/modules/ticket/infra/repositories/ticket.mongodb.repository.ts +++ b/backend/src/modules/ticket/infra/repositories/ticket.mongodb.repository.ts @@ -1,5 +1,5 @@ import { Model, QueryFilter } from 'mongoose'; -import { Ticket } from '../../domain/entities/ticket.entity'; +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 { InjectModel } from '@nestjs/mongoose'; @@ -42,6 +42,10 @@ export class TicketMongoRepository extends ITicketRepository { clientId?: string; agentId?: string; categories?: string[]; + search?: string; + status?: TicketStatus; + escalationLevel?: number; + onlyMine?: boolean; }): Promise { let query: QueryFilter = {}; @@ -56,6 +60,28 @@ export class TicketMongoRepository extends ITicketRepository { query = { clientId: filters.clientId }; } + if (filters?.search) { + query = { + ...query, + $or: [ + { title: { $regex: filters.search, $options: 'i' } }, + { description: { $regex: filters.search, $options: 'i' } }, + ], + }; + } + + if (filters?.status) { + query = { ...query, status: filters.status }; + } + + if (filters?.escalationLevel) { + query = { ...query, escalationLevel: filters.escalationLevel }; + } + + if (filters?.onlyMine && filters?.agentId) { + query = { ...query, agentId: filters.agentId }; + } + const tickets = await this.ticketModel.find(query).exec(); return tickets.map((t) => TicketMapper.toDomain(t)); } 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..93279ca 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts @@ -564,44 +564,56 @@ describe('TicketController', () => { expect(getHistoryFilteredUseCase.execute).not.toHaveBeenCalled(); }); - it('GET /tickets should return tickets filtered by agentId when role is SUPPORT', async () => { - const agentId = randomUUID(); - const categories = [randomUUID()]; - const primitives = ticket.toPrimitives(); - - const moduleFixture = await Test.createTestingModule({ - controllers: [TicketController], - providers: [ - { provide: CreateTicketUseCase, useValue: { execute: jest.fn() } }, - { provide: ReadAllTicketUseCase, useValue: { execute: jest.fn() } }, - { provide: ReadByIdTicketUseCase, useValue: { execute: jest.fn() } }, - { provide: GetHistoryTicketUseCase, useValue: { execute: jest.fn() } }, - { 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: CloseTicketUseCase, useValue: { execute: jest.fn() } }, - ], +it('GET /tickets should return tickets filtered by agentId when role is SUPPORT', async () => { + const agentId = randomUUID(); + const categories = [randomUUID()]; + const primitives = ticket.toPrimitives(); + + const moduleFixture = await Test.createTestingModule({ + controllers: [TicketController], + providers: [ + { provide: CreateTicketUseCase, useValue: { execute: jest.fn() } }, + { provide: ReadAllTicketUseCase, useValue: { execute: jest.fn() } }, + { provide: ReadByIdTicketUseCase, useValue: { execute: jest.fn() } }, + { provide: GetHistoryTicketUseCase, useValue: { execute: jest.fn() } }, + { 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: CloseTicketUseCase, useValue: { execute: jest.fn() } }, + ], + }) + .overrideGuard(JwtGuard) + .useValue({ + canActivate: (context: ExecutionContext) => { + const req = context.switchToHttp().getRequest(); + req.user = { + id: agentId, + role: UserRole.SUPPORT, + categories: categories, + }; + return true; + }, }) - .overrideGuard(JwtGuard) - .useValue({ - canActivate: (context: ExecutionContext) => { - const req = context.switchToHttp().getRequest(); - req.user = { - id: agentId, - role: UserRole.SUPPORT, - categories: categories, // consistente com o JwtStrategy - }; - return true; - }, - }) - .overrideGuard(RolesGuard) - .useValue({ canActivate: () => true }) - .compile(); - - const isolatedApp = moduleFixture.createNestApplication(); + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + const isolatedApp = moduleFixture.createNestApplication(); + + try { + isolatedApp.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await isolatedApp.init(); + const isolatedHttpServer = isolatedApp.getHttpServer() as import('http').Server; + const localReadAllUseCase = moduleFixture.get(ReadAllTicketUseCase); jest.spyOn(localReadAllUseCase, 'execute').mockResolvedValue([ @@ -621,7 +633,7 @@ describe('TicketController', () => { }, ]); - const response = await request(isolatedApp.getHttpServer()) + const response = await request(isolatedHttpServer) .get('/tickets') .expect(200); @@ -634,8 +646,13 @@ describe('TicketController', () => { userId: agentId, categories: categories, role: UserRole.SUPPORT, + search: undefined, + status: undefined, + escalationLevel: undefined, + onlyMine: false }); - + } finally { await isolatedApp.close(); - }); + } +}); }); diff --git a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts index 2811ac3..1809393 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts @@ -79,15 +79,20 @@ export class TicketController { @ApiOperation({ summary: 'Retorna todos os tickets' }) @UseGuards(JwtGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.SUPPORT, UserRole.CLIENT) - @ApiResponse({ - status: 200, - description: 'Todos os tickets retornados com sucesso.', - }) - async getAll(@Request() req: any) { + @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, @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; From 0010e5037b02674f93cfc0f417c0806265ac1be0 Mon Sep 17 00:00:00 2001 From: Gabriel Viell Castilho <162073388+GabrielViellCastilho@users.noreply.github.com> Date: Mon, 25 May 2026 07:35:06 -0300 Subject: [PATCH 02/12] Feat/notification (#45) * feat: notification * feat: add message and open ticket notification * fix: trriger event * fix: message notification * fix: list notifications * fix: format notification response payload --- backend/package-lock.json | 20 + backend/package.json | 1 + backend/src/app.module.ts | 13 +- .../Messages/presentation/message.gateway.ts | 31 +- .../application/chat.file-message.spec.ts | 79 ++- .../chat/application/chat.service.spec.ts | 472 ++++++++++-------- .../modules/chat/application/chat.service.ts | 262 ++++++++-- .../repositories/faq.mongodb.repository.ts | 104 ++-- .../dto/create-notification.dto.ts | 27 + .../listeners/received-message.listener.ts | 102 ++++ .../listeners/ticket-closed.listener.ts | 42 ++ .../listeners/ticket-open.listener.ts | 48 ++ .../services/notificatio.stream.service.ts | 77 +++ .../create-message-notification.use-case.ts | 50 ++ .../use-cases/create-notification.use-case.ts | 32 ++ .../use-cases/list-notifications.use-case.ts | 27 + .../use-cases/read-notification.use-case.ts | 38 ++ .../domain/entities/notification.entity.ts | 118 +++++ .../notification.repository.interface.ts | 14 + .../infra/mappers/notification.mapper.ts | 41 ++ .../notification.mongodb.repository.ts | 79 +++ .../schemas/notification.mongo.schema.ts | 45 ++ .../notification/notification.module.ts | 77 +++ .../controllers/notification.controller.ts | 98 ++++ .../notification.sse.controller.ts | 106 ++++ .../shared/enums/notification.enum.ts | 5 + .../useCases/close/close.usecase.spec.ts | 19 +- .../useCases/close/close.usecase.ts | 12 +- .../useCases/create/create.usecase.spec.ts | 121 ++++- .../useCases/create/create.usecase.ts | 14 + .../received-message-notification.event.ts | 21 + .../src/shared/events/ticket-closed.event.ts | 8 + .../src/shared/events/ticket-open.event.ts | 8 + 33 files changed, 1845 insertions(+), 366 deletions(-) create mode 100644 backend/src/modules/notification/application/dto/create-notification.dto.ts create mode 100644 backend/src/modules/notification/application/listeners/received-message.listener.ts create mode 100644 backend/src/modules/notification/application/listeners/ticket-closed.listener.ts create mode 100644 backend/src/modules/notification/application/listeners/ticket-open.listener.ts create mode 100644 backend/src/modules/notification/application/services/notificatio.stream.service.ts create mode 100644 backend/src/modules/notification/application/use-cases/create-message-notification.use-case.ts create mode 100644 backend/src/modules/notification/application/use-cases/create-notification.use-case.ts create mode 100644 backend/src/modules/notification/application/use-cases/list-notifications.use-case.ts create mode 100644 backend/src/modules/notification/application/use-cases/read-notification.use-case.ts create mode 100644 backend/src/modules/notification/domain/entities/notification.entity.ts create mode 100644 backend/src/modules/notification/domain/repository/notification.repository.interface.ts create mode 100644 backend/src/modules/notification/infra/mappers/notification.mapper.ts create mode 100644 backend/src/modules/notification/infra/repositories/notification.mongodb.repository.ts create mode 100644 backend/src/modules/notification/infra/schemas/notification.mongo.schema.ts create mode 100644 backend/src/modules/notification/notification.module.ts create mode 100644 backend/src/modules/notification/presetation/controllers/notification.controller.ts create mode 100644 backend/src/modules/notification/presetation/controllers/notification.sse.controller.ts create mode 100644 backend/src/modules/notification/shared/enums/notification.enum.ts create mode 100644 backend/src/shared/events/received-message-notification.event.ts create mode 100644 backend/src/shared/events/ticket-closed.event.ts create mode 100644 backend/src/shared/events/ticket-open.event.ts 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..f917a7e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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/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..d2fd742 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'; @@ -42,19 +55,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 +107,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 +121,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 +133,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 +148,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 +164,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 +176,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 +190,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..5af84e5 100644 --- a/backend/src/modules/chat/application/chat.service.spec.ts +++ b/backend/src/modules/chat/application/chat.service.spec.ts @@ -1,291 +1,321 @@ 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'), }; 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, + ); + + expect(result) + .toEqual(mockChat); - describe('getChatHistory', () => { - const mockMessages = [ - { - chatId: CHAT_ID, - senderId: CLIENT_ID, - content: 'Oi', - createdAt: new Date(), + 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, + ); + }, + ); + }); - it('should return messages when user is a participant', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); - mockMessageRepository.findByChatId.mockResolvedValue(mockMessages as any); + describe('isParticipant', () => { - const result = await service.getChatHistory( - CHAT_ID, - CLIENT_ID, - UserRole.CLIENT, - ); + it( + 'should return true when userId is the clientId', + async () => { - expect(result).toEqual(mockMessages); - expect(mockMessageRepository.findByChatId).toHaveBeenCalledWith(CHAT_ID); - }); + mockChatRepository.findById + .mockResolvedValue(mockChat); - it('should allow ADMIN to view history even if not a participant', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); - mockMessageRepository.findByChatId.mockResolvedValue(mockMessages as any); + const result = + await service.isParticipant( + CHAT_ID, + CLIENT_ID, + ); - const result = await service.getChatHistory( - CHAT_ID, - OUTSIDER_ID, - UserRole.ADMIN, - ); + expect(result) + .toBe(true); + }, + ); - expect(result).toEqual(mockMessages); - }); + it( + 'should return true when userId is the agentId', + async () => { - it('should throw ForbiddenException when SUPPORT tries to view history and is not a participant', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); + mockChatRepository.findById + .mockResolvedValue(mockChat); - await expect( - service.getChatHistory(CHAT_ID, OUTSIDER_ID, UserRole.SUPPORT), - ).rejects.toThrow(ForbiddenException); + const result = + await service.isParticipant( + CHAT_ID, + AGENT_ID, + ); - expect(mockMessageRepository.findByChatId).not.toHaveBeenCalled(); - }); + expect(result) + .toBe(true); + }, + ); - it('should throw ForbiddenException when user is not a participant', async () => { - mockChatRepository.findById.mockResolvedValue(mockChat); + it( + 'should return false when userId is not a participant', + async () => { - await expect( - service.getChatHistory(CHAT_ID, OUTSIDER_ID, UserRole.CLIENT), - ).rejects.toThrow(ForbiddenException); + mockChatRepository.findById + .mockResolvedValue(mockChat); - expect(mockMessageRepository.findByChatId).not.toHaveBeenCalled(); - }); - }); + const result = + await service.isParticipant( + CHAT_ID, + OUTSIDER_ID, + ); - describe('closeChat', () => { - it('should close the chat and return updated details', async () => { - const closedChat = { ...mockChat, status: ChatStatus.CLOSED }; - mockChatRepository.updateStatus.mockResolvedValue(closedChat); + expect(result) + .toBe(false); + }, + ); - const result = await service.closeChat(CHAT_ID); + it( + 'should return false when chat does not exist', + async () => { - expect(result.status).toBe(ChatStatus.CLOSED); - expect(mockChatRepository.updateStatus).toHaveBeenCalledWith( - CHAT_ID, - ChatStatus.CLOSED, - ); - }); - }); - - 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..2ce0e2d 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,145 @@ 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(), + ), + ); + + console.log( + `[ChatService] Evento ${NotificationType.NEW_MESSAGE} disparado`, + ); + + return savedMessage; } async getChatHistory( @@ -86,62 +208,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/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/notification/application/dto/create-notification.dto.ts b/backend/src/modules/notification/application/dto/create-notification.dto.ts new file mode 100644 index 0000000..5f6071e --- /dev/null +++ b/backend/src/modules/notification/application/dto/create-notification.dto.ts @@ -0,0 +1,27 @@ +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; + + messageId: string; + + contentPreview: string; + + createdAt: Date; +} \ No newline at end of file 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..d462453 --- /dev/null +++ b/backend/src/modules/notification/application/listeners/received-message.listener.ts @@ -0,0 +1,102 @@ +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, + + 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, + + 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..ec69dda --- /dev/null +++ b/backend/src/modules/notification/application/use-cases/create-message-notification.use-case.ts @@ -0,0 +1,50 @@ +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, + }); + + 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..f77a170 --- /dev/null +++ b/backend/src/modules/notification/notification.module.ts @@ -0,0 +1,77 @@ +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 { 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, + + { + 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..a6d5fe7 --- /dev/null +++ b/backend/src/modules/notification/shared/enums/notification.enum.ts @@ -0,0 +1,5 @@ +export enum NotificationType { + TICKET_CLOSED = 'ticket_closed', + TICKET_OPEN = 'ticket_open', + NEW_MESSAGE = 'new_message', +} \ 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/shared/events/received-message-notification.event.ts b/backend/src/shared/events/received-message-notification.event.ts new file mode 100644 index 0000000..245dc5b --- /dev/null +++ b/backend/src/shared/events/received-message-notification.event.ts @@ -0,0 +1,21 @@ +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, + ) {} +} \ 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 From 3af3e39f0679bc16cffc58fc6845f87d3c7996ba Mon Sep 17 00:00:00 2001 From: Davi Miyake Batista <165038725+DaviMBDev@users.noreply.github.com> Date: Mon, 25 May 2026 09:23:14 -0300 Subject: [PATCH 03/12] feat/back-front-integration (#46) * feat/back-front-integration * fix:passar o pipeline --------- Co-authored-by: Vinicius Elias <95414991+ViniElias@users.noreply.github.com> --- .../application/chat.file-message.spec.ts | 8 ++++ .../chat/application/chat.service.spec.ts | 8 ++++ .../chat/presentation/chat.controller.ts | 5 +++ .../controllers/ticket.controller.ts | 44 ++++++++++++++++++ .../dtos/updateTicketStatus.dto.ts | 45 +++++++++++++++++++ 5 files changed, 110 insertions(+) create mode 100644 backend/src/modules/ticket/presentation/dtos/updateTicketStatus.dto.ts 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 d2fd742..6c12953 100644 --- a/backend/src/modules/chat/application/chat.file-message.spec.ts +++ b/backend/src/modules/chat/application/chat.file-message.spec.ts @@ -48,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; diff --git a/backend/src/modules/chat/application/chat.service.spec.ts b/backend/src/modules/chat/application/chat.service.spec.ts index 5af84e5..570a3ab 100644 --- a/backend/src/modules/chat/application/chat.service.spec.ts +++ b/backend/src/modules/chat/application/chat.service.spec.ts @@ -98,6 +98,14 @@ const mockChat: ChatDetails = { new Date('2026-01-01'), }; +const mockTicketQuery = { + exec: jest.fn().mockResolvedValue(null), +}; + +const mockTicketModel = { + findById: jest.fn().mockReturnValue(mockTicketQuery), +}; + describe('ChatService', () => { let service: ChatService; 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/ticket/presentation/controllers/ticket.controller.ts b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts index 1809393..bfbb27d 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, @@ -210,4 +212,46 @@ export class TicketController { 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) + @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, + 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/updateTicketStatus.dto.ts b/backend/src/modules/ticket/presentation/dtos/updateTicketStatus.dto.ts new file mode 100644 index 0000000..49b6980 --- /dev/null +++ b/backend/src/modules/ticket/presentation/dtos/updateTicketStatus.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsEnum, IsOptional } 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; +} From 896774a7854e10cb373e297a3321ad3108475f30 Mon Sep 17 00:00:00 2001 From: Humbertoishii <163201524+HumbertoIshii@users.noreply.github.com> Date: Tue, 26 May 2026 11:53:48 -0300 Subject: [PATCH 04/12] =?UTF-8?q?Escalonamento=20de=20N=C3=ADvel=20(Back)?= =?UTF-8?q?=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(ticket): Ajustar nível de escalonamento Atualiza a rota de escalonamento para permitir alteração do nível de escalonamento do ticket. * fix(ticket): Corrige escalate e testes de controller e entity --- .../useCases/escalate/escalate.usecase.ts | 3 ++- .../domain/entities/tickect.entity.spec.ts | 25 +++++++++++++++--- .../ticket/domain/entities/ticket.entity.ts | 26 ++++++++++++++----- .../controllers/ticket.controller.spec.ts | 3 ++- .../controllers/ticket.controller.ts | 1 + .../presentation/dtos/escalateTicket.dto.ts | 11 +++++++- .../dtos/updateTicketStatus.dto.ts | 13 ++++++++-- .../presentation/mappers/ticket.mapper.ts | 1 + 8 files changed, 68 insertions(+), 15 deletions(-) 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/domain/entities/tickect.entity.spec.ts b/backend/src/modules/ticket/domain/entities/tickect.entity.spec.ts index 224ec5f..8835c59 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,35 @@ 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 throw an error when closing a ticket with ESCALATED status', () => { + ticket.assignToAgent(randomUUID()); + + ticket.escalate(randomUUID(), 1, 'web_app'); + + expect(ticket.status).toBe(TicketStatus.ESCALATED); + + expect(() => ticket.close('Test solution')).toThrow( + TicketValidationErrors.CLOSE_WITH_WRONG_STATUS_ERROR, + ); + }); + 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...'); @@ -123,7 +142,7 @@ describe('Ticket entity', () => { it('Should throw an error when closing a ticket with ESCALATED status', () => { ticket.assignToAgent(randomUUID()); - ticket.escalate(randomUUID(), 'web_app'); + ticket.escalate(randomUUID(),1, 'web_app'); expect(ticket.status).toBe(TicketStatus.ESCALATED); expect(() => ticket.close('Test solution')).toThrow( diff --git a/backend/src/modules/ticket/domain/entities/ticket.entity.ts b/backend/src/modules/ticket/domain/entities/ticket.entity.ts index bdc7e78..6686424 100644 --- a/backend/src/modules/ticket/domain/entities/ticket.entity.ts +++ b/backend/src/modules/ticket/domain/entities/ticket.entity.ts @@ -224,7 +224,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 +239,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; 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 93279ca..d87a605 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts @@ -306,6 +306,7 @@ describe('TicketController', () => { const payload = { groupUD: randomUUID(), + escalationLevel: 1, category: 'iot', }; @@ -331,7 +332,7 @@ describe('TicketController', () => { const groupId = randomUUID(); ticket.assignToAgent(agentId); - ticket.escalate(groupId, 'iot'); + ticket.escalate(groupId,1, 'iot'); const primitives = ticket.toPrimitives(); diff --git a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts index bfbb27d..9b4ed19 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts @@ -236,6 +236,7 @@ export class TicketController { } const data = TicketMapper.toEscalateTicketInput(id, { groupId: body.groupId, + escalationLevel: body.escalationLevel, category: body.category || '', whatWasDone: body.whatWasDone || '', }); 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 index 49b6980..d7ea1f1 100644 --- a/backend/src/modules/ticket/presentation/dtos/updateTicketStatus.dto.ts +++ b/backend/src/modules/ticket/presentation/dtos/updateTicketStatus.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString, IsNotEmpty, IsEnum, IsOptional } from 'class-validator'; +import { IsString, IsNotEmpty, IsEnum, IsOptional, IsNumber } from 'class-validator'; import { TicketStatus } from '../../domain/entities/ticket.entity'; export class UpdateTicketStatusRequest { @@ -42,4 +42,13 @@ export class UpdateTicketStatusRequest { @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, }; From dc5048ee1b9201e42c179053fda701082a9550ef Mon Sep 17 00:00:00 2001 From: Renan Tomasi <160744095+renan21-tg@users.noreply.github.com> Date: Tue, 26 May 2026 18:00:00 -0300 Subject: [PATCH 05/12] fix: Adicionando TicketID nas notificacoes (#48) --- backend/src/modules/chat/application/chat.service.ts | 2 ++ .../notification/application/dto/create-notification.dto.ts | 2 ++ .../application/listeners/received-message.listener.ts | 6 ++++++ .../use-cases/create-message-notification.use-case.ts | 1 + .../shared/events/received-message-notification.event.ts | 2 ++ 5 files changed, 13 insertions(+) diff --git a/backend/src/modules/chat/application/chat.service.ts b/backend/src/modules/chat/application/chat.service.ts index 2ce0e2d..c184907 100644 --- a/backend/src/modules/chat/application/chat.service.ts +++ b/backend/src/modules/chat/application/chat.service.ts @@ -193,6 +193,8 @@ export class ChatService { savedMessage.type || 'TEXT', new Date(), + + chat.ticketId, // Adicionado aqui ), ); diff --git a/backend/src/modules/notification/application/dto/create-notification.dto.ts b/backend/src/modules/notification/application/dto/create-notification.dto.ts index 5f6071e..a72f125 100644 --- a/backend/src/modules/notification/application/dto/create-notification.dto.ts +++ b/backend/src/modules/notification/application/dto/create-notification.dto.ts @@ -19,6 +19,8 @@ export interface CreateMessageNotificationDTO { chatId: string; + ticketId?: string; + messageId: string; contentPreview: string; diff --git a/backend/src/modules/notification/application/listeners/received-message.listener.ts b/backend/src/modules/notification/application/listeners/received-message.listener.ts index d462453..9e7f76e 100644 --- a/backend/src/modules/notification/application/listeners/received-message.listener.ts +++ b/backend/src/modules/notification/application/listeners/received-message.listener.ts @@ -51,6 +51,9 @@ export class ReceivedMessageListener { chatId: event.chatId, + ticketId: + event.ticketId, + messageId: event.messageId, @@ -75,6 +78,9 @@ export class ReceivedMessageListener { chatId: event.chatId, + ticketId: // Adicionado aqui para o frontend SSE + event.ticketId, + messageId: event.messageId, 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 index ec69dda..8c34bd0 100644 --- 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 @@ -39,6 +39,7 @@ export class CreateMessageNotificationUseCase { type: NotificationType.NEW_MESSAGE, chatId: data.chatId, + ticketId: data.ticketId, }); await this.notificationRepository.create( diff --git a/backend/src/shared/events/received-message-notification.event.ts b/backend/src/shared/events/received-message-notification.event.ts index 245dc5b..6a95ed1 100644 --- a/backend/src/shared/events/received-message-notification.event.ts +++ b/backend/src/shared/events/received-message-notification.event.ts @@ -17,5 +17,7 @@ export class ReceivedMessageNotificationEvent { public readonly type: string, public readonly createdAt: Date, + + public readonly ticketId?: string, ) {} } \ No newline at end of file From 8ae3e588457bf20c91e9ce7d573b4d7cd5c7e6a3 Mon Sep 17 00:00:00 2001 From: Diego Castilho <163672892+DigoCast@users.noreply.github.com> Date: Tue, 26 May 2026 23:34:16 -0300 Subject: [PATCH 06/12] fix: change agentid and groupid to deafult null (#49) --- backend/src/modules/chat/infra/chat.schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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'] }) From 94d72c7840863e12460a818bd4b1340186b82daf Mon Sep 17 00:00:00 2001 From: Gabriel Viell Castilho <162073388+GabrielViellCastilho@users.noreply.github.com> Date: Wed, 27 May 2026 11:45:25 -0300 Subject: [PATCH 07/12] feat: add notification when access request is created (#50) --- backend/shared/events/access-request.event.ts | 8 ++ .../dto/create-notification.dto.ts | 1 + .../listeners/access-request.listener.ts | 39 ++++++++++ .../notification/notification.module.ts | 3 + .../shared/enums/notification.enum.ts | 1 + .../modules/user/user.access-request.spec.ts | 38 ++++++++-- backend/src/modules/user/user.service.spec.ts | 76 +++++++++++++++---- backend/src/modules/user/user.service.ts | 15 ++++ 8 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 backend/shared/events/access-request.event.ts create mode 100644 backend/src/modules/notification/application/listeners/access-request.listener.ts 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/modules/notification/application/dto/create-notification.dto.ts b/backend/src/modules/notification/application/dto/create-notification.dto.ts index a72f125..f3cfd3f 100644 --- a/backend/src/modules/notification/application/dto/create-notification.dto.ts +++ b/backend/src/modules/notification/application/dto/create-notification.dto.ts @@ -10,6 +10,7 @@ export interface CreateNotificationDTO { ticketId?: string; } + export interface CreateMessageNotificationDTO { receiverId: string; 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/notification.module.ts b/backend/src/modules/notification/notification.module.ts index f77a170..d897bf9 100644 --- a/backend/src/modules/notification/notification.module.ts +++ b/backend/src/modules/notification/notification.module.ts @@ -22,10 +22,12 @@ import { NotificationSSEController } 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"; @@ -57,6 +59,7 @@ import { ReadNotificationUseCase } from "./application/use-cases/read-notificati TicketClosedListener, TicketOpenListener, ReceivedMessageListener, + AccessRequestListener, { provide: INotificationRepository, diff --git a/backend/src/modules/notification/shared/enums/notification.enum.ts b/backend/src/modules/notification/shared/enums/notification.enum.ts index a6d5fe7..3ae5a2e 100644 --- a/backend/src/modules/notification/shared/enums/notification.enum.ts +++ b/backend/src/modules/notification/shared/enums/notification.enum.ts @@ -2,4 +2,5 @@ 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/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' }; } From 000e10989f03a416c96f628df0ef3166dea35b44 Mon Sep 17 00:00:00 2001 From: Diego Castilho <163672892+DigoCast@users.noreply.github.com> Date: Sun, 31 May 2026 12:09:32 -0300 Subject: [PATCH 08/12] feat: Client can close a ticket (#52) --- .../domain/entities/tickect.entity.spec.ts | 25 ++++++++----------- .../ticket/domain/entities/ticket.entity.ts | 2 +- .../controllers/ticket.controller.ts | 2 +- 3 files changed, 13 insertions(+), 16 deletions(-) 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 8835c59..6235f62 100644 --- a/backend/src/modules/ticket/domain/entities/tickect.entity.spec.ts +++ b/backend/src/modules/ticket/domain/entities/tickect.entity.spec.ts @@ -103,16 +103,14 @@ describe('Ticket entity', () => { ); }); - it('Should throw an error when closing a ticket with ESCALATED status', () => { + 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); - expect(() => ticket.close('Test solution')).toThrow( - TicketValidationErrors.CLOSE_WITH_WRONG_STATUS_ERROR, - ); + 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', () => { @@ -133,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(),1, '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, ); @@ -157,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 6686424..f31b478 100644 --- a/backend/src/modules/ticket/domain/entities/ticket.entity.ts +++ b/backend/src/modules/ticket/domain/entities/ticket.entity.ts @@ -271,7 +271,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/presentation/controllers/ticket.controller.ts b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts index 9b4ed19..1d573e1 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts @@ -218,7 +218,7 @@ export class TicketController { @ApiParam({ name: 'id', example: 'uuid-do-ticket' }) @ApiBody({ type: UpdateTicketStatusRequest }) @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.SUPPORT) + @Roles(UserRole.ADMIN, UserRole.SUPPORT, UserRole.CLIENT) @ApiResponse({ status: 200, description: 'Status do ticket alterado com sucesso.' }) async updateStatus( @Request() req: any, From 17ac79fc92d4fdb61feb3b244355aed1f5dd19c2 Mon Sep 17 00:00:00 2001 From: Ygor Pereira <107441120+YgorPereira@users.noreply.github.com> Date: Sun, 31 May 2026 12:17:23 -0300 Subject: [PATCH 09/12] Feat/ticket agent aggregate (#44) * refactor: add agent field with id and agent name * refactor: read all use case now returns agent field * fix: field agent doesnt sent in get response * refactor: separate aggregate helper and aggregate category and client * refactor: return aggregated objects in get tickets responses * fix: fixing tests and remove unecessary consoles * fix: fix category id on new agent route --- .../useCases/newAgent/newAgent.usecase.ts | 9 +- .../useCases/readAll/readAll.usecase.spec.ts | 1 - .../useCases/readAll/readAll.usecase.ts | 13 +- .../useCases/readById/readById.usecase.ts | 10 +- .../ticket/domain/entities/ticket.entity.ts | 22 ++- .../ticket.mongodb.repository.int.spec.ts | 2 - .../infra/helpers/ticket.aggregate.builder.ts | 130 ++++++++++++++++++ .../ticket/infra/mappers/ticket.mapper.ts | 14 +- .../repositories/ticket.mongodb.repository.ts | 48 ++++--- .../infra/schemas/ticket.mongo.schema.ts | 9 +- .../controllers/ticket.controller.ts | 21 +-- 11 files changed, 233 insertions(+), 46 deletions(-) create mode 100644 backend/src/modules/ticket/infra/helpers/ticket.aggregate.builder.ts 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 5d84392..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() @@ -50,7 +51,7 @@ export class ReadAllTicketUseCase { onlyMine: input.onlyMine, }); - const convertedTickets = foundedTickets.map((t: Ticket) => { + const convertedTickets = foundedTickets.map((t) => { const primitive = t.toPrimitives(); return { @@ -59,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/ticket.entity.ts b/backend/src/modules/ticket/domain/entities/ticket.entity.ts index f31b478..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, 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..276126b 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(); 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..a659773 --- /dev/null +++ b/backend/src/modules/ticket/infra/helpers/ticket.aggregate.builder.ts @@ -0,0 +1,130 @@ +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', 'agentId', 'categoryData', '__v'], + }; + } + + static buildAggregate() { + return [ + ...this.agentLookup(), + ...this.categoryLookup(), + ...this.clientLookup(), + this.cleanup(), + ]; + } +} 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 a17faa9..78a4e25 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,11 @@ -import { Model, QueryFilter } from 'mongoose'; +import { Model } 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 { InjectModel } from '@nestjs/mongoose'; import { TicketMapper } from '../mappers/ticket.mapper'; +import { TicketAggregateBuilder } from '../helpers/ticket.aggregate.builder'; +// import TicketAggregateBuilder from '../helpers/ticket.aggregate.builder'; export class TicketMongoRepository extends ITicketRepository { constructor( @@ -25,7 +27,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() @@ -47,22 +50,22 @@ export class TicketMongoRepository extends ITicketRepository { 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 }; } if (filters?.search) { - query = { - ...query, + matchStage = { + ...matchStage, $or: [ { title: { $regex: filters.search, $options: 'i' } }, { description: { $regex: filters.search, $options: 'i' } }, @@ -71,30 +74,41 @@ export class TicketMongoRepository extends ITicketRepository { } if (filters?.status) { - query = { ...query, status: filters.status }; + matchStage = { ...matchStage, status: filters.status }; } if (filters?.escalationLevel) { - query = { ...query, escalationLevel: filters.escalationLevel }; + matchStage = { ...matchStage, escalationLevel: filters.escalationLevel }; } if (filters?.onlyMine && filters?.agentId) { - query = { ...query, agentId: filters.agentId }; + matchStage = { ...matchStage, agentId: filters.agentId }; } - const tickets = await this.ticketModel.find(query).exec(); - return tickets.map((t) => TicketMapper.toDomain(t)); + 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 { 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..49a66d2 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,8 @@ 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; +}; diff --git a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts index 1d573e1..f9223d1 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts @@ -45,7 +45,10 @@ 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'; @ApiTags('Ticket') @Controller('tickets') @@ -61,7 +64,7 @@ export class TicketController { private readonly newAgentUseCase: NewAgentTicketUseCase, private readonly deleteUseCase: DeleteTicketUseCase, private readonly closeUseCase: CloseTicketUseCase, - ) { } + ) {} @Post() @ApiOperation({ summary: 'Cria um ticket' }) @@ -85,7 +88,10 @@ export class TicketController { @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.' }) + @ApiResponse({ + status: 200, + description: 'Todos os tickets retornados com sucesso.', + }) async getAll(@Request() req: any, @Query() query: any) { const response = await this.readAllUseCase.execute({ userId: req.user.id, @@ -93,7 +99,9 @@ export class TicketController { role: req.user.role, search: query.search, status: query.status as TicketStatus, - escalationLevel: query.escalationLevel ? Number(query.escalationLevel) : undefined, + escalationLevel: query.escalationLevel + ? Number(query.escalationLevel) + : undefined, onlyMine: query.onlyMine === 'true', }); @@ -202,10 +210,7 @@ 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); From 48aacd610aa0daa705193d3d0ac91ca946f23cd3 Mon Sep 17 00:00:00 2001 From: Gabriel Viell Castilho <162073388+GabrielViellCastilho@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:20:43 -0300 Subject: [PATCH 10/12] fix: preserve agentId in aggregate pipeline (#54) --- .../modules/ticket/infra/helpers/ticket.aggregate.builder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/modules/ticket/infra/helpers/ticket.aggregate.builder.ts b/backend/src/modules/ticket/infra/helpers/ticket.aggregate.builder.ts index a659773..06f830b 100644 --- a/backend/src/modules/ticket/infra/helpers/ticket.aggregate.builder.ts +++ b/backend/src/modules/ticket/infra/helpers/ticket.aggregate.builder.ts @@ -115,7 +115,7 @@ export class TicketAggregateBuilder { static cleanup() { return { - $unset: ['agentData', 'agentId', 'categoryData', '__v'], + $unset: ['agentData', 'categoryData', '__v'], }; } From a036ea8b43e5fefbe414236a47134a376a559dbe Mon Sep 17 00:00:00 2001 From: Ygor Pereira <107441120+YgorPereira@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:52:02 -0300 Subject: [PATCH 11/12] Feat/ticket metrics operations (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Merge pull request #1 from Bug-Busters-F/chore/init-nest-project Chore/init nest project * Merge/develop into main (#19) * feat: new nest project * feat: v1 ci pipeline * chore: new pull request template and module example * fix: fixing dep name * fix other dep name * feat(database): Adicionar conexão com MongoDB Configura conexão inicial com MongoDB usando Mongoose na camada de infraestrutura, incluindo tratamento básico de falhas. * docs(backend): Add .env.example e guia de execucao Adicionar .env.example para configuracao inicial. Atualizar CONTRIBUTING.md com instrucoes de execucao do projeto. * refactor(database): Renomear infra para database Renomeia a pasta infra para database para refletir melhor a responsabilidade do módulo. Corrige o database.module.ts para carregar a URI do MongoDB via variáveis de ambiente corretamente. * Chore/messages schema (#3) * add message-schema * Continuação da estruturação da pasta Messages * Arquivo principal, camada de Domain e Infra de Mensagens * feat: database in memory for integration tests (#5) * feat: database in memory for integration tests * feat: add integration tests step in ci pipeline * Feat/websocket (#7) * Estrutura do WebSocket, ainda não finalizada * Alteração no app.module.ts para testes. Funcionalidade do WebSocket testada * Refact: Schema (message.schema.ts) e dos Repositórios (message.repository.ts e message.repository.mongodb.ts) Feat: Websocket com Socket.IO * Feat/user module (#6) * feat/auth * feat: add auth, group, user, company modules. * feat: add swagger * fix: validate related entities before creating user * fix: UserController defined test * fix: regenerate package-lock.json * Feat/triage module (#9) * feat(triage): serviço de categorização automática Desenvolve serviço de triagem que analisa descrições de chamados utilizando NLP.js para sugerir automaticamente a categoria ou setor responsável com base em palavras-chave. Também remove arquivos duplicados para corrigir inconsistências. * feat(triage): Ajustar treino NLP e criar DTO teste Ajustar os dados de treinamento do NLP e as rules para garantir consistência com as categorias definidas Criar DTO para possibilitar testes via rota exposta no NestJS utilizando Swagger para validação do módulo * fix(triage): Corrigir fallback Normalizar o retorno do NLP para evitar que a intent "None" seja considerada uma categoria válida durante a classificação Garantir que casos sem correspondência retornem corretamente a categoria OTHER através do fallback definido no serviço * feat(tests): Adiciona testes automaticos * Feat/user test (#10) * feat: add user test * fix: remove duplicate package.json * Feat/ticket module (#8) * feat: tickect domain entity structure * feat: hydrating ticket domain entity * feat: finished entity model for ticket artefact with tests * refactor: new enums to erros and event messages * feat: ticket schema v1 * feat: initialize repository with an abstract class and test * feat: ticket repository v1 * feat: finish repository with save function and improve the tests * refactor: remove priority fiel from create and constructor input * feat: usecases v1 * fix: fixing jest int config json * feat: finished application layer v1 * feat: finish controller v1 * feat: module configuration and inject dependency * feat: add ticket routes on swagger --------- Co-authored-by: YgorPereira * Feat/category module (#13) * feat(category): Adicionar modulo de categorias Implementa o modulo de categorias de atendimento no sistema. Inclui controller, service e integracao com MongoDB. Permite criar, listar, atualizar e remover categorias. Adiciona suporte a keywords, trainingPhrases e groupIds. * fix(category): Validar existencia de grupos Adiciona verificacao para garantir que os grupos existem ao criar ou editar uma categoria * fix(category): Adicionar GroupService nos testes Adiciona mock do GroupService para resolver erro de injecao de dependencia no CategoryService durante os testes de integracao * Feat/chat module (#12) * test: add unit tests * feat: chat module v1 * Add files via upload * refactor: allign chat module with ticket schema (agentId and groupId) * refactor: integration guide * test: test_mongo.js * fix: correção crash no socket.io * fix: remove extensões .js nos imports e atualiza dependências --------- Co-authored-by: Davi Miyake Batista * fix: fix create of chats and generate UUID on repository (#14) * fix: add valitation flags on login dto (#15) * Feat/ticket triage integration (#16) * feat(category): Adiciona Categorias Iniciais Adiciona categorias iniciais com keywords e trainingPhrases para suporte à triagem automática com NLP.js. * feat(triage): Integrar categorias com triagem Integra o módulo de categorias com o fluxo de triagem, permitindo que regras e NLP utilizem dados dinâmicos vindos do banco de dados. Adiciona método de busca por nome no CategoryService para suportar fallback e resolução de categorias. * feat(ticket): Integrar triagem na criação de chamado Modifica o fluxo de criação de chamados para que a descrição seja analisada previamente pelo serviço de triagem automática, garantindo categorização antes do registro no banco de dados. Integra o módulo de triagem ao módulo de ticket e ajusta o TicketCategory para uso como string, pois as categorias agora são persistidas no banco de dados. * fix(rebase): Resolver conflito da rebase Corrige um conflito que não havia sido alterado quando foi feito a rebase da develop * test(ticket): Ajustar specs para categorias dinâmicas Atualiza os testes para remover o uso de TicketCategory e alinhar com o novo fluxo de categorias via triagem. * fix: roles for supports to allow support to entry in chats (#17) * fix: merge conflicts --------- Co-authored-by: YgorPereira Co-authored-by: YgorPereira Co-authored-by: Humberto Co-authored-by: Humbertoishii <163201524+HumbertoIshii@users.noreply.github.com> Co-authored-by: Vinicius Elias <95414991+ViniElias@users.noreply.github.com> Co-authored-by: Ygor Pereira <107441120+YgorPereira@users.noreply.github.com> Co-authored-by: Davi Miyake Batista <165038725+DaviMBDev@users.noreply.github.com> Co-authored-by: Davi Miyake Batista Co-authored-by: Diego Castilho <163672892+DigoCast@users.noreply.github.com> * refactor: add agent field with id and agent name * refactor: read all use case now returns agent field * fix: field agent doesnt sent in get response * refactor: separate aggregate helper and aggregate category and client * refactor: return aggregated objects in get tickets responses * fix: fixing tests and remove unecessary consoles * fix: fix category id on new agent route * feat: add build metrics on * feat: add get metrics use case * feat: add metric routes * feat: add average time to open and close ticket * feat:integrar com nova funcionalidade do front --------- Co-authored-by: Gabriel Viell Castilho <162073388+GabrielViellCastilho@users.noreply.github.com> Co-authored-by: YgorPereira Co-authored-by: Humberto Co-authored-by: Humbertoishii <163201524+HumbertoIshii@users.noreply.github.com> Co-authored-by: Vinicius Elias <95414991+ViniElias@users.noreply.github.com> Co-authored-by: Davi Miyake Batista <165038725+DaviMBDev@users.noreply.github.com> Co-authored-by: Davi Miyake Batista Co-authored-by: Diego Castilho <163672892+DigoCast@users.noreply.github.com> Co-authored-by: Gabriel Viell Castilho Co-authored-by: DaviMBDev --- backend/src/app.controller.spec.ts | 22 ++ backend/src/app.controller.ts | 12 + backend/src/app.service.ts | 8 + .../src/modules/group/dtos/createGroupDTO.ts | 13 ++ .../src/modules/group/dtos/updateGroupDTO.ts | 14 ++ backend/src/modules/group/group.controller.ts | 131 +++++++++++ backend/src/modules/group/group.interface.ts | 5 + backend/src/modules/group/group.module.ts | 15 ++ backend/src/modules/group/group.schema.ts | 14 ++ .../src/modules/group/group.service.spec.ts | 104 +++++++++ backend/src/modules/group/group.service.ts | 70 ++++++ .../shared/domain/ticket-category.enum.ts | 7 + .../getMetrics/getMetrics.usecase.spec.ts | 66 ++++++ .../useCases/getMetrics/getMetrics.usecase.ts | 54 +++++ .../ticket.mongodb.repository.int.spec.ts | 197 ++++++++++++++++ .../repository/ticket.repository.interface.ts | 6 + .../infra/helpers/ticket.aggregate.builder.ts | 49 ++++ .../repositories/ticket.mongodb.repository.ts | 63 ++++- .../infra/schemas/ticket.mongo.schema.ts | 22 ++ .../controllers/ticket.controller.spec.ts | 219 +++++++++++------- .../controllers/ticket.controller.ts | 41 +++- .../presentation/dtos/assignAgent.dto.ts | 10 + backend/src/modules/ticket/ticket.module.ts | 2 + .../triage/domain/triage-result.type.ts | 5 + .../modules/user/dtos/changeGroupUserDTO.ts | 8 + backend/test/app.e2e-spec.ts | 25 ++ backend/test/jest-e2e.json | 9 + 27 files changed, 1098 insertions(+), 93 deletions(-) create mode 100644 backend/src/app.controller.spec.ts create mode 100644 backend/src/app.controller.ts create mode 100644 backend/src/app.service.ts create mode 100644 backend/src/modules/group/dtos/createGroupDTO.ts create mode 100644 backend/src/modules/group/dtos/updateGroupDTO.ts create mode 100644 backend/src/modules/group/group.controller.ts create mode 100644 backend/src/modules/group/group.interface.ts create mode 100644 backend/src/modules/group/group.module.ts create mode 100644 backend/src/modules/group/group.schema.ts create mode 100644 backend/src/modules/group/group.service.spec.ts create mode 100644 backend/src/modules/group/group.service.ts create mode 100644 backend/src/modules/shared/domain/ticket-category.enum.ts create mode 100644 backend/src/modules/ticket/application/useCases/getMetrics/getMetrics.usecase.spec.ts create mode 100644 backend/src/modules/ticket/application/useCases/getMetrics/getMetrics.usecase.ts create mode 100644 backend/src/modules/ticket/presentation/dtos/assignAgent.dto.ts create mode 100644 backend/src/modules/triage/domain/triage-result.type.ts create mode 100644 backend/src/modules/user/dtos/changeGroupUserDTO.ts create mode 100644 backend/test/app.e2e-spec.ts create mode 100644 backend/test/jest-e2e.json diff --git a/backend/src/app.controller.spec.ts b/backend/src/app.controller.spec.ts new file mode 100644 index 0000000..d22f389 --- /dev/null +++ b/backend/src/app.controller.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +describe('AppController', () => { + let appController: AppController; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); + + appController = app.get(AppController); + }); + + describe('root', () => { + it('should return "Hello World!"', () => { + expect(appController.getHello()).toBe('Hello World!'); + }); + }); +}); diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts new file mode 100644 index 0000000..cce879e --- /dev/null +++ b/backend/src/app.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get() + getHello(): string { + return this.appService.getHello(); + } +} diff --git a/backend/src/app.service.ts b/backend/src/app.service.ts new file mode 100644 index 0000000..927d7cc --- /dev/null +++ b/backend/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHello(): string { + return 'Hello World!'; + } +} diff --git a/backend/src/modules/group/dtos/createGroupDTO.ts b/backend/src/modules/group/dtos/createGroupDTO.ts new file mode 100644 index 0000000..ed1cace --- /dev/null +++ b/backend/src/modules/group/dtos/createGroupDTO.ts @@ -0,0 +1,13 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional } from 'class-validator'; + +export class CreateGroupDTO { + @ApiProperty({ example: 'Support Team' }) + @IsString() + name: string; + + @ApiPropertyOptional({ example: 'Grupo responsável pelo suporte' }) + @IsOptional() + @IsString() + description?: string; +} diff --git a/backend/src/modules/group/dtos/updateGroupDTO.ts b/backend/src/modules/group/dtos/updateGroupDTO.ts new file mode 100644 index 0000000..e3ca633 --- /dev/null +++ b/backend/src/modules/group/dtos/updateGroupDTO.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional } from 'class-validator'; + +export class UpdateGroupDTO { + @ApiPropertyOptional({ example: 'Support Team Updated' }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ example: 'Nova descrição' }) + @IsOptional() + @IsString() + description?: string; +} diff --git a/backend/src/modules/group/group.controller.ts b/backend/src/modules/group/group.controller.ts new file mode 100644 index 0000000..9143918 --- /dev/null +++ b/backend/src/modules/group/group.controller.ts @@ -0,0 +1,131 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Patch, + Delete, + UseGuards, +} from '@nestjs/common'; + +import { GroupService } from './group.service'; +import { CreateGroupDTO } from './dtos/createGroupDTO'; +import { UpdateGroupDTO } from './dtos/updateGroupDTO'; +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 { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { UserRole } from '../shared/enums/user.enum'; + +@ApiTags('Group') +@ApiBearerAuth() +@Controller('group') +export class GroupController { + constructor(private groupService: GroupService) {} + + @Post() + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Criar grupo' }) + @ApiBody({ type: CreateGroupDTO }) + @ApiResponse({ status: 201, description: 'Grupo criado com sucesso' }) + @ApiResponse({ + status: 401, + description: 'Não autenticado (token inválido ou ausente)', + }) + @ApiResponse({ + status: 403, + description: 'Acesso negado (somente ADMIN)', + }) + createGroup(@Body() group: CreateGroupDTO): Promise { + return this.groupService.createGroup(group.name, group.description); + } + + @Get() + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPPORT) + @ApiOperation({ summary: 'Listar todos os grupos' }) + @ApiResponse({ status: 200, description: 'Lista de grupos retornada' }) + @ApiResponse({ + status: 401, + description: 'Não autenticado (token inválido ou ausente)', + }) + @ApiResponse({ + status: 403, + description: 'Acesso negado (somente ADMIN ou SUPPORT)', + }) + getAllGroups(): Promise { + return this.groupService.findAll(); + } + + @Get(':id') + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPPORT) + @ApiOperation({ summary: 'Buscar grupo por ID' }) + @ApiParam({ name: 'id', example: '65f1a2b3c9d123456789abcd' }) + @ApiResponse({ status: 200, description: 'Grupo encontrado' }) + @ApiResponse({ status: 404, description: 'Grupo não encontrado' }) + @ApiResponse({ + status: 401, + description: 'Não autenticado (token inválido ou ausente)', + }) + @ApiResponse({ + status: 403, + description: 'Acesso negado (somente ADMIN ou SUPPORT)', + }) + getGroup(@Param('id') id: string): Promise { + return this.groupService.findById(id); + } + + @Patch(':id') + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Atualizar grupo' }) + @ApiParam({ name: 'id', example: '65f1a2b3c9d123456789abcd' }) + @ApiBody({ type: UpdateGroupDTO }) + @ApiResponse({ status: 200, description: 'Grupo atualizado' }) + @ApiResponse({ status: 404, description: 'Grupo não encontrado' }) + @ApiResponse({ + status: 401, + description: 'Não autenticado (token inválido ou ausente)', + }) + @ApiResponse({ + status: 403, + description: 'Acesso negado (somente ADMIN)', + }) + updateGroup( + @Param('id') id: string, + @Body() data: UpdateGroupDTO, + ): Promise { + return this.groupService.updateGroup(id, data); + } + + @Delete(':id') + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Deletar grupo' }) + @ApiParam({ name: 'id', example: '65f1a2b3c9d123456789abcd' }) + @ApiResponse({ status: 200, description: 'Grupo deletado' }) + @ApiResponse({ status: 404, description: 'Grupo não encontrado' }) + @ApiResponse({ + status: 401, + description: 'Não autenticado (token inválido ou ausente)', + }) + @ApiResponse({ + status: 403, + description: 'Acesso negado (somente ADMIN)', + }) + deleteGroup(@Param('id') id: string): Promise { + return this.groupService.deleteGroup(id); + } +} diff --git a/backend/src/modules/group/group.interface.ts b/backend/src/modules/group/group.interface.ts new file mode 100644 index 0000000..a9870e2 --- /dev/null +++ b/backend/src/modules/group/group.interface.ts @@ -0,0 +1,5 @@ +export interface GroupDetails { + id: string; + name: string; + description: string; +} diff --git a/backend/src/modules/group/group.module.ts b/backend/src/modules/group/group.module.ts new file mode 100644 index 0000000..3ee2949 --- /dev/null +++ b/backend/src/modules/group/group.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { GroupController } from './group.controller'; +import { GroupService } from './group.service'; +import { MongooseModule } from '@nestjs/mongoose'; +import { GroupSchema } from './group.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: 'Group', schema: GroupSchema }]), + ], + controllers: [GroupController], + providers: [GroupService], + exports: [GroupService], +}) +export class GroupModule {} diff --git a/backend/src/modules/group/group.schema.ts b/backend/src/modules/group/group.schema.ts new file mode 100644 index 0000000..b5ebeb2 --- /dev/null +++ b/backend/src/modules/group/group.schema.ts @@ -0,0 +1,14 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; + +export type GroupDocument = Group & Document; + +@Schema() +export class Group { + @Prop({ required: true, unique: true }) + name: string; + + @Prop({ required: false }) + description: string; +} + +export const GroupSchema = SchemaFactory.createForClass(Group); diff --git a/backend/src/modules/group/group.service.spec.ts b/backend/src/modules/group/group.service.spec.ts new file mode 100644 index 0000000..c9c3bfe --- /dev/null +++ b/backend/src/modules/group/group.service.spec.ts @@ -0,0 +1,104 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MongooseModule, getConnectionToken } from '@nestjs/mongoose'; +import { Connection } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; + +import { GroupService } from './group.service'; +import { GroupSchema } from './group.schema'; + +describe('GroupService (Integration)', () => { + let service: GroupService; + let connection: Connection; + let mongod: MongoMemoryServer; + + beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + MongooseModule.forRoot(mongod.getUri()), + MongooseModule.forFeature([{ name: 'Group', schema: GroupSchema }]), + ], + providers: [GroupService], + }).compile(); + + service = module.get(GroupService); + connection = module.get(getConnectionToken()); + }); + + afterEach(async () => { + const collections = connection.collections; + for (const key in collections) { + await collections[key].deleteMany({}); + } + }); + + afterAll(async () => { + await connection.close(); + await mongod.stop(); + }); + + it('should create a group successfully', async () => { + const group = await service.createGroup('Support', 'Support team'); + + expect(group).toBeDefined(); + expect(group.name).toBe('Support'); + expect(group.description).toBe('Support team'); + }); + + it('should return all groups', async () => { + await service.createGroup('G1'); + await service.createGroup('G2'); + + const groups = await service.findAll(); + + expect(groups.length).toBe(2); + }); + + it('should find group by id', async () => { + const created = await service.createGroup('Test Group'); + + const found = await service.findById(created.id.toString()); + + expect(found).toBeDefined(); + expect(found.name).toBe('Test Group'); + }); + + it('should throw error when group not found', async () => { + await expect(service.findById('507f1f77bcf86cd799439011')).rejects.toThrow( + 'Group not found', + ); + }); + + it('should update group', async () => { + const created = await service.createGroup('Old Name'); + + const updated = await service.updateGroup(created.id.toString(), { + name: 'New Name', + }); + + expect(updated.name).toBe('New Name'); + }); + + it('should throw error when updating non-existent group', async () => { + await expect( + service.updateGroup('507f1f77bcf86cd799439011', { + name: 'Fail', + }), + ).rejects.toThrow('Group not found'); + }); + + it('should delete group', async () => { + const created = await service.createGroup('To Delete'); + + await service.deleteGroup(created.id.toString()); + + await expect(service.findById(created.id.toString())).rejects.toThrow(); + }); + + it('should throw error when deleting non-existent group', async () => { + await expect( + service.deleteGroup('507f1f77bcf86cd799439011'), + ).rejects.toThrow('Group not found'); + }); +}); diff --git a/backend/src/modules/group/group.service.ts b/backend/src/modules/group/group.service.ts new file mode 100644 index 0000000..9b0d000 --- /dev/null +++ b/backend/src/modules/group/group.service.ts @@ -0,0 +1,70 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { GroupDocument } from './group.schema'; +import { GroupDetails } from './group.interface'; + +@Injectable() +export class GroupService { + constructor( + @InjectModel('Group') private readonly groupModel: Model, + ) {} + + async findAll(): Promise { + const groups = await this.groupModel.find().exec(); + + return groups.map((group) => this._getGroup(group)); + } + + async findById(id: string): Promise { + const group = await this.groupModel.findById(id).exec(); + + if (!group) { + throw new NotFoundException('Group not found'); + } + + return this._getGroup(group); + } + + async createGroup(name: string, description?: string): Promise { + const newGroup = new this.groupModel({ + name, + description, + }); + + const savedGroup = await newGroup.save(); + + return this._getGroup(savedGroup); + } + + async updateGroup( + id: string, + data: Partial, + ): Promise { + const updatedGroup = await this.groupModel + .findByIdAndUpdate(id, data, { new: true }) + .exec(); + + if (!updatedGroup) { + throw new NotFoundException('Group not found'); + } + + return this._getGroup(updatedGroup); + } + + async deleteGroup(id: string): Promise { + const deletedGroup = await this.groupModel.findByIdAndDelete(id).exec(); + + if (!deletedGroup) { + throw new NotFoundException('Group not found'); + } + } + + private _getGroup(group: any): GroupDetails { + return { + id: group._id, + name: group.name, + description: group.description, + }; + } +} diff --git a/backend/src/modules/shared/domain/ticket-category.enum.ts b/backend/src/modules/shared/domain/ticket-category.enum.ts new file mode 100644 index 0000000..285371b --- /dev/null +++ b/backend/src/modules/shared/domain/ticket-category.enum.ts @@ -0,0 +1,7 @@ +export enum TicketCategory { + WEB_APP = 'WEB_APP', + IA = 'ARTIFICIAL_INTELLIGENCE', + BI = 'BUSINESS_INTELLIGENCE', + IOT = 'INTERNET_OF_THINGS', + OTHER = 'OTHER', +} 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/domain/repository/ticket.mongodb.repository.int.spec.ts b/backend/src/modules/ticket/domain/repository/ticket.mongodb.repository.int.spec.ts index 276126b..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 @@ -280,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 1420269..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, TicketStatus } from '../entities/ticket.entity'; +import { TicketMetrics } from '../../infra/schemas/ticket.mongo.schema'; @Injectable() export abstract class ITicketRepository { @@ -16,4 +17,9 @@ export abstract class ITicketRepository { }): 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 index 06f830b..12c70da 100644 --- a/backend/src/modules/ticket/infra/helpers/ticket.aggregate.builder.ts +++ b/backend/src/modules/ticket/infra/helpers/ticket.aggregate.builder.ts @@ -127,4 +127,53 @@ export class TicketAggregateBuilder { 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/repositories/ticket.mongodb.repository.ts b/backend/src/modules/ticket/infra/repositories/ticket.mongodb.repository.ts index 78a4e25..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,11 +1,15 @@ -import { Model } from 'mongoose'; +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'; -// import TicketAggregateBuilder from '../helpers/ticket.aggregate.builder'; export class TicketMongoRepository extends ITicketRepository { constructor( @@ -115,4 +119,57 @@ export class TicketMongoRepository extends ITicketRepository { 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 49a66d2..0b99bbf 100644 --- a/backend/src/modules/ticket/infra/schemas/ticket.mongo.schema.ts +++ b/backend/src/modules/ticket/infra/schemas/ticket.mongo.schema.ts @@ -97,3 +97,25 @@ 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 d87a605..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(() => { @@ -332,7 +338,7 @@ describe('TicketController', () => { const groupId = randomUUID(); ticket.assignToAgent(agentId); - ticket.escalate(groupId,1, 'iot'); + ticket.escalate(groupId, 1, 'iot'); const primitives = ticket.toPrimitives(); @@ -565,95 +571,138 @@ describe('TicketController', () => { expect(getHistoryFilteredUseCase.execute).not.toHaveBeenCalled(); }); -it('GET /tickets should return tickets filtered by agentId when role is SUPPORT', async () => { - const agentId = randomUUID(); - const categories = [randomUUID()]; - const primitives = ticket.toPrimitives(); - - const moduleFixture = await Test.createTestingModule({ - controllers: [TicketController], - providers: [ - { provide: CreateTicketUseCase, useValue: { execute: jest.fn() } }, - { provide: ReadAllTicketUseCase, useValue: { execute: jest.fn() } }, - { provide: ReadByIdTicketUseCase, useValue: { execute: jest.fn() } }, - { provide: GetHistoryTicketUseCase, useValue: { execute: jest.fn() } }, - { 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: CloseTicketUseCase, useValue: { execute: jest.fn() } }, - ], - }) - .overrideGuard(JwtGuard) - .useValue({ - canActivate: (context: ExecutionContext) => { - const req = context.switchToHttp().getRequest(); - req.user = { - id: agentId, - role: UserRole.SUPPORT, - categories: categories, - }; - return true; - }, + it('GET /tickets should return tickets filtered by agentId when role is SUPPORT', async () => { + const agentId = randomUUID(); + const categories = [randomUUID()]; + const primitives = ticket.toPrimitives(); + + const moduleFixture = await Test.createTestingModule({ + controllers: [TicketController], + providers: [ + { provide: CreateTicketUseCase, useValue: { execute: jest.fn() } }, + { provide: ReadAllTicketUseCase, useValue: { execute: jest.fn() } }, + { provide: ReadByIdTicketUseCase, useValue: { execute: jest.fn() } }, + { provide: GetHistoryTicketUseCase, useValue: { execute: jest.fn() } }, + { 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: CloseTicketUseCase, useValue: { execute: jest.fn() } }, + { provide: GetMetricsUseCase, useValue: { execute: jest.fn() } }, + ], }) - .overrideGuard(RolesGuard) - .useValue({ canActivate: () => true }) - .compile(); - - const isolatedApp = moduleFixture.createNestApplication(); - - try { - isolatedApp.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ); - - await isolatedApp.init(); + .overrideGuard(JwtGuard) + .useValue({ + canActivate: (context: ExecutionContext) => { + const req = context.switchToHttp().getRequest(); + req.user = { + id: agentId, + role: UserRole.SUPPORT, + categories: categories, + }; + return true; + }, + }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); - const isolatedHttpServer = isolatedApp.getHttpServer() as import('http').Server; + const isolatedApp = moduleFixture.createNestApplication(); - 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); + + 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(); + } + }); - const response = await request(isolatedHttpServer) - .get('/tickets') + 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, - search: undefined, - status: undefined, - escalationLevel: undefined, - onlyMine: false - }); - } finally { - await isolatedApp.close(); - } -}); + 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); + + 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 f9223d1..fb989fc 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts @@ -49,6 +49,7 @@ import { TicketEvents, TicketStatus, } from '../../domain/entities/ticket.entity'; +import { GetMetricsUseCase } from '../../application/useCases/getMetrics/getMetrics.usecase'; @ApiTags('Ticket') @Controller('tickets') @@ -64,6 +65,7 @@ export class TicketController { private readonly newAgentUseCase: NewAgentTicketUseCase, private readonly deleteUseCase: DeleteTicketUseCase, private readonly closeUseCase: CloseTicketUseCase, + private readonly getMetricsUseCase: GetMetricsUseCase, ) {} @Post() @@ -108,6 +110,26 @@ export class TicketController { 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' }) @@ -219,12 +241,17 @@ export class TicketController { } @Put(':id/status') - @ApiOperation({ summary: 'Altera o status de um ticket de forma genérica e integrada' }) + @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.' }) + @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, @@ -250,7 +277,9 @@ export class TicketController { if (body.status === TicketStatus.CLOSED) { if (!body.solution) { - throw new BadRequestException('Fechamento do chamado exige uma solução descrita.'); + throw new BadRequestException( + 'Fechamento do chamado exige uma solução descrita.', + ); } const data = TicketMapper.toCloseTicketInput(id, { solution: body.solution, @@ -258,6 +287,8 @@ export class TicketController { return await this.closeUseCase.execute(data); } - throw new BadRequestException('Transição de status inválida ou não suportada.'); + throw new BadRequestException( + 'Transição de status inválida ou não suportada.', + ); } } diff --git a/backend/src/modules/ticket/presentation/dtos/assignAgent.dto.ts b/backend/src/modules/ticket/presentation/dtos/assignAgent.dto.ts new file mode 100644 index 0000000..8c12b92 --- /dev/null +++ b/backend/src/modules/ticket/presentation/dtos/assignAgent.dto.ts @@ -0,0 +1,10 @@ +import { IsUUID, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { randomUUID } from 'crypto'; + +export class AssignAgentRequest { + @ApiProperty({ example: randomUUID(), description: 'ID do agente' }) + @IsUUID() + @IsString() + agentId!: string; +} 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/triage/domain/triage-result.type.ts b/backend/src/modules/triage/domain/triage-result.type.ts new file mode 100644 index 0000000..c2f2f78 --- /dev/null +++ b/backend/src/modules/triage/domain/triage-result.type.ts @@ -0,0 +1,5 @@ +export type TriageResult = { + category: string; + confidence: number; + source: 'rule' | 'nlp' | 'fallback'; +}; diff --git a/backend/src/modules/user/dtos/changeGroupUserDTO.ts b/backend/src/modules/user/dtos/changeGroupUserDTO.ts new file mode 100644 index 0000000..6c28a4b --- /dev/null +++ b/backend/src/modules/user/dtos/changeGroupUserDTO.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class ChangeGroupUserDTO { + @ApiProperty({ example: '65f1a2b3c9d123456789abcd' }) + @IsString() + groupId: string; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts new file mode 100644 index 0000000..36852c5 --- /dev/null +++ b/backend/test/app.e2e-spec.ts @@ -0,0 +1,25 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from './../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + return request(app.getHttpServer()) + .get('/') + .expect(200) + .expect('Hello World!'); + }); +}); diff --git a/backend/test/jest-e2e.json b/backend/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/backend/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} From 665e61177f2cffb3e0ac989db85fbaab0d33c5ca Mon Sep 17 00:00:00 2001 From: Ygor Pereira <107441120+YgorPereira@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:52:50 -0300 Subject: [PATCH 12/12] feat: docker file v1 (#53) --- backend/.dockerignore | 5 +++++ backend/Dockerfile | 15 +++++++++++++++ backend/package.json | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile 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.json b/backend/package.json index f917a7e..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",