diff --git a/.gitignore b/.gitignore index 5d4b8e9..a004445 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,9 @@ temp/ # ====================== # Files local storage # ====================== -uploads/ \ No newline at end of file +uploads/ + +# ====================== +# AI fast generated models +# ====================== +backend/model.nlp diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 4450372..c2add19 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -18,7 +18,7 @@ import { } from '../user/dtos/createUserDTO'; import { UserDetails } from '../user/user.interface'; import { ExistingUserDTO } from '../user/dtos/existingUserDTO'; -import { UserRole } from '../user/user.schema'; +import { UserRole } from '../shared/enums/user.enum'; import { Roles } from './guards/roles.decorator'; import { JwtGuard } from './guards/jwt.guard'; import { RolesGuard } from './guards/roles.guard'; diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 6677036..5d5e6de 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -10,7 +10,7 @@ import { CreateUserDTO } from '../user/dtos/createUserDTO'; import { UserDetails } from '../user/user.interface'; import { ExistingUserDTO } from '../user/dtos/existingUserDTO'; import { JwtService } from '@nestjs/jwt'; -import { UserRole } from '../user/user.schema'; +import { UserRole } from '../shared/enums/user.enum'; import { EmailService } from '../email/email.service'; @Injectable() @@ -85,6 +85,7 @@ export class AuthService { sub: user.id, email: user.email, role: user.role, + categories: user.categories ?? undefined, }); return { token: jwt }; } diff --git a/backend/src/modules/auth/guards/jwt.strategy.ts b/backend/src/modules/auth/guards/jwt.strategy.ts index a427243..a48c912 100644 --- a/backend/src/modules/auth/guards/jwt.strategy.ts +++ b/backend/src/modules/auth/guards/jwt.strategy.ts @@ -17,6 +17,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { id: payload.sub, email: payload.email, role: payload.role, + categories: (payload.categories ?? []).map((c) => c.id), }; } } diff --git a/backend/src/modules/auth/guards/roles.decorator.ts b/backend/src/modules/auth/guards/roles.decorator.ts index 4745a48..8f13696 100644 --- a/backend/src/modules/auth/guards/roles.decorator.ts +++ b/backend/src/modules/auth/guards/roles.decorator.ts @@ -1,5 +1,5 @@ import { SetMetadata } from '@nestjs/common'; -import { UserRole } from '../../user/user.schema'; +import { UserRole } from '../../shared/enums/user.enum'; export const ROLES_KEY = 'roles'; diff --git a/backend/src/modules/auth/guards/roles.guard.ts b/backend/src/modules/auth/guards/roles.guard.ts index 92f56a8..9537efb 100644 --- a/backend/src/modules/auth/guards/roles.guard.ts +++ b/backend/src/modules/auth/guards/roles.guard.ts @@ -7,7 +7,7 @@ import { import { Reflector } from '@nestjs/core'; import { ROLES_KEY } from './roles.decorator'; -import { UserRole } from '../../user/user.schema'; +import { UserRole } from '../../shared/enums/user.enum'; @Injectable() export class RolesGuard implements CanActivate { diff --git a/backend/src/modules/category/category.controller.ts b/backend/src/modules/category/category.controller.ts index 65d36f9..83e5663 100644 --- a/backend/src/modules/category/category.controller.ts +++ b/backend/src/modules/category/category.controller.ts @@ -13,7 +13,6 @@ import { CategoryDetails } from './category.interface'; import { Roles } from '../auth/guards/roles.decorator'; import { JwtGuard } from '../auth/guards/jwt.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; -import { UserRole } from '../user/user.schema'; import { ApiBearerAuth, ApiBody, @@ -24,6 +23,7 @@ import { } from '@nestjs/swagger'; import { CreateCategoryDTO } from './dtos/createCategoryDTO'; import { UpdateCategoryDTO } from './dtos/updateCategoryDTO'; +import { UserRole } from '../shared/enums/user.enum'; @ApiTags('Category') @ApiBearerAuth() 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 45576d9..422fc42 100644 --- a/backend/src/modules/chat/application/chat.file-message.spec.ts +++ b/backend/src/modules/chat/application/chat.file-message.spec.ts @@ -2,11 +2,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getModelToken } from '@nestjs/mongoose'; import { ChatService } from './chat.service'; import { TicketSchemaClass } from '../../ticket/infra/schemas/ticket.mongo.schema'; -import { UserRole } from '../../user/user.schema'; import { NotFoundException } from '@nestjs/common'; 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 = { create: jest.fn(), diff --git a/backend/src/modules/chat/application/chat.service.spec.ts b/backend/src/modules/chat/application/chat.service.spec.ts index 92bca6c..ddd510a 100644 --- a/backend/src/modules/chat/application/chat.service.spec.ts +++ b/backend/src/modules/chat/application/chat.service.spec.ts @@ -6,7 +6,7 @@ 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 '../../user/user.schema'; +import { UserRole } from '../../shared/enums/user.enum'; const mockChatRepository: jest.Mocked = { create: jest.fn(), diff --git a/backend/src/modules/chat/application/chat.service.ts b/backend/src/modules/chat/application/chat.service.ts index 9750525..fddf75e 100644 --- a/backend/src/modules/chat/application/chat.service.ts +++ b/backend/src/modules/chat/application/chat.service.ts @@ -8,10 +8,12 @@ import type { IChatRepository } from '../domain/chat.repository'; import type { IMessageRepository } from '../../Messages/domain/message.repository'; import type { ChatDetails } from '../domain/chat.entity'; import { ChatStatus } from '../domain/chat.entity'; -import { User, UserDocument, UserRole } from '../../user/user.schema'; +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'; @Injectable() export class ChatService { diff --git a/backend/src/modules/company/company.controller.ts b/backend/src/modules/company/company.controller.ts index 72d9cac..40a251f 100644 --- a/backend/src/modules/company/company.controller.ts +++ b/backend/src/modules/company/company.controller.ts @@ -16,7 +16,6 @@ import { CompanyDetails } from './company.interface'; import { JwtGuard } from '../auth/guards/jwt.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/guards/roles.decorator'; -import { UserRole } from '../user/user.schema'; import { ApiBearerAuth, ApiBody, @@ -25,6 +24,7 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; +import { UserRole } from '../shared/enums/user.enum'; @ApiTags('Company') @ApiBearerAuth() diff --git a/backend/src/modules/faq/presentation/controllers/faq.controller.ts b/backend/src/modules/faq/presentation/controllers/faq.controller.ts index 2b62e11..d60d980 100644 --- a/backend/src/modules/faq/presentation/controllers/faq.controller.ts +++ b/backend/src/modules/faq/presentation/controllers/faq.controller.ts @@ -7,11 +7,11 @@ import { ReadAllFaqUseCase } from "../../application/readAll/readAll.usecase"; import { ReadByIdFaqUseCase } from "../../application/readById/readById.usecase"; import { JwtGuard } from "../../../auth/guards/jwt.guard"; import { RolesGuard } from "../../../auth/guards/roles.guard"; -import { UserRole } from "../../../user/user.schema"; import { CreateFaqRequest } from "../dtos/create.dto"; import { FaqMapper } from "../mappers/faq.mapper"; import { Roles } from "../../../auth/guards/roles.decorator"; import { UpdateFaqRequest } from "../dtos/update.dto"; +import { UserRole } from "../../../shared/enums/user.enum"; @ApiTags('FAQ') @ApiBearerAuth() diff --git a/backend/src/modules/file/presentation/file.controller.ts b/backend/src/modules/file/presentation/file.controller.ts index f667e05..23292f3 100644 --- a/backend/src/modules/file/presentation/file.controller.ts +++ b/backend/src/modules/file/presentation/file.controller.ts @@ -4,12 +4,12 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { JwtGuard } from '../../auth/guards/jwt.guard'; import { RolesGuard } from '../../auth/guards/roles.guard'; import { Roles } from '../../auth/guards/roles.decorator'; -import { UserRole } from '../../user/user.schema'; import { FileService } from '../application/file.service'; import { UploadFileDTO } from './dtos/uploadFileDTO'; import { multerConfig } from '../config/multer.config'; import type { Express, Request } from 'express'; import { UploadChatFileParamsDTO } from './dtos/uploadChatFileParamsDTO'; +import { UserRole } from '../../shared/enums/user.enum'; import { profileMulterConfig } from '../config/profile-multer.config'; import type { Response } from 'express'; diff --git a/backend/src/modules/shared/enums/user.enum.ts b/backend/src/modules/shared/enums/user.enum.ts new file mode 100644 index 0000000..898d858 --- /dev/null +++ b/backend/src/modules/shared/enums/user.enum.ts @@ -0,0 +1,5 @@ +export enum UserRole { + CLIENT = 'client', + SUPPORT = 'support', + ADMIN = 'admin', +} 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 771b699..8592f6f 100644 --- a/backend/src/modules/ticket/application/useCases/create/create.usecase.ts +++ b/backend/src/modules/ticket/application/useCases/create/create.usecase.ts @@ -6,7 +6,6 @@ import { TriageService } from '../../../../triage/application/triage.service'; export interface CreateTicketInput { title: string; description: string; - clientId: string; level?: number; } @@ -29,12 +28,16 @@ export class CreateTicketUseCase { private readonly triageService: TriageService, ) {} - async execute(input: CreateTicketInput): Promise { + async execute( + input: CreateTicketInput, + clientId: string, + ): Promise { const triageResult = await this.triageService.classify(input.description); const ticket = Ticket.create({ ...input, category: triageResult.category, + clientId: clientId, }); const created = await this.repository.create(ticket); 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 3e88dae..e8b0543 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 @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/unbound-method */ +import { describe, expect, it, beforeEach, jest } from '@jest/globals'; import { randomUUID } from 'crypto'; import { ITicketRepository } from '../../../domain/repository/ticket.repository.interface'; import { Ticket } from '../../../domain/entities/ticket.entity'; import { ReadAllTicketUseCase } from './readAll.usecase'; +import { UserRole } from '../../../../shared/enums/user.enum'; describe('ReadAllTicketUseCase', () => { let repository: jest.Mocked; @@ -27,7 +29,10 @@ describe('ReadAllTicketUseCase', () => { it('should read all ticket successfully', async () => { repository.readAll.mockResolvedValue([ticket]); - const output = await useCase.execute(); + const output = await useCase.execute({ + userId: randomUUID(), + role: UserRole.SUPPORT, + }); expect(output).toBeDefined(); expect(Array.isArray(output)).toBe(true); @@ -39,4 +44,75 @@ describe('ReadAllTicketUseCase', () => { expect(output).not.toHaveProperty('escalate'); expect(output).not.toHaveProperty('assignToAgent'); }); + + it('should read all tickets with clientId filter', async () => { + repository.readAll.mockResolvedValue([ticket]); + const output = await useCase.execute({ + userId: ticket.clientId, + role: UserRole.CLIENT, + }); + expect(output).toBeDefined(); + expect(Array.isArray(output)).toBe(true); + expect(output[0].clientId).toBe(ticket.clientId); + expect(repository.readAll).toHaveBeenCalledWith({ + clientId: ticket.clientId, + }); + + expect(output).not.toHaveProperty('toPrimitives'); + expect(output).not.toHaveProperty('escalate'); + expect(output).not.toHaveProperty('assignToAgent'); + }); + + it('should read all tickets associated to the support agent or unassigned in their group', async () => { + const supId = randomUUID(); + const categories = [randomUUID()]; + + const ticketAssignedToAgent = Ticket.create({ + title: 'ticket atribuído ao agente', + category: categories[0], + description: 'descricao', + clientId: randomUUID(), + }); + ticketAssignedToAgent.assignToAgent(supId); + + const ticketUnassignedInGroup = Ticket.create({ + title: 'ticket sem agente no grupo', + category: randomUUID(), + description: 'descricao', + clientId: randomUUID(), + }); + + const ticketOtherAgent = Ticket.create({ + title: 'ticket de outro agente', + category: categories[0], + description: 'descricao', + clientId: randomUUID(), + }); + ticketOtherAgent.assignToAgent(randomUUID()); + + repository.readAll.mockResolvedValue([ + ticketAssignedToAgent, + ticketUnassignedInGroup, + ]); + + const output = await useCase.execute({ + userId: supId, + categories: categories, + role: UserRole.SUPPORT, + }); + + expect(output).toBeDefined(); + expect(Array.isArray(output)).toBe(true); + expect(output).toHaveLength(2); + + expect(repository.readAll).toHaveBeenCalledWith({ + agentId: supId, + categories: categories, + }); + + // Garante que retornou primitivos, não instâncias do domínio + expect(output[0]).not.toHaveProperty('toPrimitives'); + expect(output[0]).not.toHaveProperty('escalate'); + expect(output[0]).not.toHaveProperty('assignToAgent'); + }); }); 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 7216da6..8462df7 100644 --- a/backend/src/modules/ticket/application/useCases/readAll/readAll.usecase.ts +++ b/backend/src/modules/ticket/application/useCases/readAll/readAll.usecase.ts @@ -5,6 +5,7 @@ import { TicketStatus, } from '../../../domain/entities/ticket.entity'; import { ITicketRepository } from '../../../domain/repository/ticket.repository.interface'; +import { UserRole } from '../../../../shared/enums/user.enum'; export interface ReadAllTicketOutput { id: string; @@ -15,7 +16,6 @@ export interface ReadAllTicketOutput { clientId: string; status: TicketStatus; agentId: string | null; - groupId: string | null; escalationLevel: number; createdAt: Date; updatedAt: Date | null; @@ -26,8 +26,19 @@ export interface ReadAllTicketOutput { export class ReadAllTicketUseCase { constructor(private readonly repository: ITicketRepository) {} - async execute(): Promise { - const foundedTickets = await this.repository.readAll(); + async execute(input: { + userId: string; + categories?: string[]; + role: UserRole; + }): Promise { + const filters = + input.role === UserRole.CLIENT + ? { clientId: input.userId } + : input.role === UserRole.SUPPORT + ? { agentId: input.userId, categories: input.categories } + : undefined; + + const foundedTickets = await this.repository.readAll({ ...filters }); const convertedTickets = foundedTickets.map((t: Ticket) => { const primitive = t.toPrimitives(); @@ -41,7 +52,6 @@ export class ReadAllTicketUseCase { clientId: primitive.clientId, status: primitive.status, agentId: primitive.agentId, - groupId: primitive.groupId, escalationLevel: primitive.escalationLevel, createdAt: primitive.createdAt, updatedAt: primitive.updatedAt, 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 400fa32..5582b84 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 @@ -1,3 +1,11 @@ +import { + describe, + it, + expect, + beforeAll, + afterAll, + afterEach, +} from '@jest/globals'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { getConnectionToken, MongooseModule } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; @@ -59,7 +67,7 @@ describe('ITicketRepository', () => { it('Should Create a ticket successfully', async () => { const ticketToCreate = Ticket.create({ title: 'chamado 1', - category: 'ia', + category: randomUUID(), description: 'descricao do chamado 1', clientId: randomUUID(), }); @@ -83,7 +91,7 @@ describe('ITicketRepository', () => { it('Should read all successfully', async () => { const ticketToCreate = Ticket.create({ title: 'chamado 2', - category: 'bi', + category: randomUUID(), description: 'descricao do chamado 2', clientId: randomUUID(), }); @@ -100,9 +108,11 @@ describe('ITicketRepository', () => { }); it('Should read a ticket by id successfully', async () => { + const categoryId = randomUUID(); + const ticketToCreate = Ticket.create({ title: 'chamado 3', - category: 'bi', + category: categoryId, description: 'descricao do chamado 3', clientId: randomUUID(), }); @@ -117,7 +127,7 @@ describe('ITicketRepository', () => { const primitiveResult = resultById?.toPrimitives(); expect(primitiveResult?.title).toBe('chamado 3'); - expect(primitiveResult?.category).toBe('bi'); + expect(primitiveResult?.category).toBe(categoryId); expect(primitiveResult?.priority).toBe(TicketPriority.LOW); expect(primitiveResult?.description).toBe('descricao do chamado 3'); }); @@ -130,7 +140,7 @@ describe('ITicketRepository', () => { it('Should Save a ticket successfully', async () => { const ticketToCreate = Ticket.create({ title: 'chamado 5', - category: 'bi', + category: randomUUID(), description: 'descricao do chamado 5', clientId: randomUUID(), }); @@ -154,7 +164,7 @@ describe('ITicketRepository', () => { it('Should return null when try to save a non-existent ticket', async () => { const ticket = Ticket.create({ title: 'chamado 5', - category: 'bi', + category: randomUUID(), description: 'descricao do chamado 5', clientId: randomUUID(), }); @@ -169,7 +179,7 @@ describe('ITicketRepository', () => { it('Should delete a ticket by id successfully', async () => { const ticketToCreate = Ticket.create({ title: 'chamado 5', - category: 'bi', + category: randomUUID(), description: 'descricao do chamado 5', clientId: randomUUID(), }); @@ -188,7 +198,88 @@ describe('ITicketRepository', () => { }); it('Should return false when try to delete a non-existent ticket', async () => { - const deleteResult = await repository.delete(randomUUID()); + const deleteResult = await repository.delete(randomUUID()); expect(deleteResult).toBe(false); }); + + it('Should read all tickets by clientId successfully', async () => { + const clientId = randomUUID(); + + const ticket1 = Ticket.create({ + title: 'chamado 6', + category: randomUUID(), + description: 'descricao do chamado 6', + clientId, + }); + + const ticket2 = Ticket.create({ + title: 'chamado 7', + category: randomUUID(), + description: 'descricao do chamado 7', + clientId, + }); + + await repository.create(ticket1); + await repository.create(ticket2); + + const resultReadAll = await repository.readAll({ clientId }); + expect(resultReadAll).toBeDefined(); + expect(Array.isArray(resultReadAll)).toBe(true); + expect(resultReadAll.length).toBe(2); + resultReadAll.map((t) => expect(t).toBeInstanceOf(Ticket)); + + const resultReadAllWithAnotherClientId = await repository.readAll({ + clientId: randomUUID(), + }); + expect(resultReadAllWithAnotherClientId).toBeDefined(); + expect(Array.isArray(resultReadAllWithAnotherClientId)).toBe(true); + expect(resultReadAllWithAnotherClientId.length).toBe(0); + }); + + it('should read all tickets by agentId or unassigned tickets in the same group', async () => { + const agentId = randomUUID(); + const categoryId = randomUUID(); + + const ticket1 = Ticket.create({ + title: 'chamado 1', + category: categoryId, + description: 'atribuído ao agente', + clientId: randomUUID(), + }); + ticket1.assignToAgent(agentId); + + const ticket2 = Ticket.create({ + title: 'chamado 2', + category: categoryId, + description: 'sem agente no grupo', + clientId: randomUUID(), + }); + + const ticket3 = Ticket.create({ + title: 'chamado 3', + category: categoryId, + description: 'outro agente atribuído', + clientId: randomUUID(), + }); + ticket3.assignToAgent(randomUUID()); + + await repository.create(ticket1); + await repository.create(ticket2); + await repository.create(ticket3); + + const result = await repository.readAll({ + agentId, + categories: [categoryId], + }); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(2); + result.forEach((t) => expect(t).toBeInstanceOf(Ticket)); + + const ids = result.map((t) => t.id); + expect(ids).toContain(ticket1.id); + expect(ids).toContain(ticket2.id); + expect(ids).not.toContain(ticket3.id); + }); }); 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 ca77f65..a62ab13 100644 --- a/backend/src/modules/ticket/domain/repository/ticket.repository.interface.ts +++ b/backend/src/modules/ticket/domain/repository/ticket.repository.interface.ts @@ -5,7 +5,11 @@ import { Ticket } from '../entities/ticket.entity'; export abstract class ITicketRepository { abstract create(ticket: Ticket): Promise; abstract save(ticket: Ticket): Promise; - abstract readAll(filter?: any): Promise; + abstract readAll(filters?: { + clientId?: string; + agentId?: string; + categories?: string[]; + }): 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 8276cb6..bdf6325 100644 --- a/backend/src/modules/ticket/infra/repositories/ticket.mongodb.repository.ts +++ b/backend/src/modules/ticket/infra/repositories/ticket.mongodb.repository.ts @@ -1,4 +1,4 @@ -import { Model } from 'mongoose'; +import { Model, QueryFilter } from 'mongoose'; import { Ticket } from '../../domain/entities/ticket.entity'; import { ITicketRepository } from '../../domain/repository/ticket.repository.interface'; import { TicketLean, TicketSchemaClass } from '../schemas/ticket.mongo.schema'; @@ -38,11 +38,26 @@ export class TicketMongoRepository extends ITicketRepository { return TicketMapper.toDomain(updated); } - async readAll(): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const tickets = await this.ticketModel.find(); - const mappedTickets = tickets.map((t) => TicketMapper.toDomain(t)); - return mappedTickets; + async readAll(filters?: { + clientId?: string; + agentId?: string; + categories?: string[]; + }): Promise { + let query: QueryFilter = {}; + + if (filters?.agentId) { + query = { + $or: [ + { agentId: filters.agentId }, + { category: { $in: filters.categories ?? [] }, agentId: null }, + ], + }; + } else if (filters?.clientId) { + query = { clientId: filters.clientId }; + } + + const tickets = await this.ticketModel.find(query).exec(); + return tickets.map((t) => TicketMapper.toDomain(t)); } async readById(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 862c114..17ca095 100644 --- a/backend/src/modules/ticket/infra/schemas/ticket.mongo.schema.ts +++ b/backend/src/modules/ticket/infra/schemas/ticket.mongo.schema.ts @@ -1,5 +1,5 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument, Document } from 'mongoose'; +import { HydratedDocument } from 'mongoose'; import { TicketEventMessage, @@ -47,7 +47,7 @@ export class TicketSchemaClass { @Prop({ required: true }) description: string; - @Prop({ required: true }) + @Prop({ type: String, required: true }) category: string; @Prop({ required: true, enum: Object.values(TicketPriority) }) 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 a8aa484..880101a 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts @@ -9,10 +9,21 @@ import { ReadAllTicketUseCase } from '../../application/useCases/readAll/readAll import { ReadByIdTicketUseCase } from '../../application/useCases/readById/readById.usecase'; import { TicketController } from './ticket.controller'; import { DeleteTicketUseCase } from '../../application/useCases/delete/delete.usecase'; -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import { Ticket, TicketEvents, TicketStatus } from '../../domain/entities/ticket.entity'; +import { + ExecutionContext, + INestApplication, + ValidationPipe, +} from '@nestjs/common'; +import { + Ticket, + TicketStatus, + TicketEvents, +} from '../../domain/entities/ticket.entity'; import { randomUUID } from 'crypto'; import request from 'supertest'; +import { JwtGuard } from '../../../auth/guards/jwt.guard'; +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'; @@ -32,7 +43,7 @@ describe('TicketController', () => { const ticketData = { title: 'chamado 1', - category: 'bi', + category: randomUUID(), description: 'descricao do chamado 1', clientId: randomUUID(), }; @@ -82,8 +93,24 @@ describe('TicketController', () => { provide: GetHistoryTicketUseCase, useValue: { execute: jest.fn() }, }, + ], - }).compile(); + }) + .overrideGuard(JwtGuard) + .useValue({ + canActivate: (context: ExecutionContext) => { + const req = context.switchToHttp().getRequest(); + req.user = { + id: randomUUID(), + role: UserRole.ADMIN, + groupId: randomUUID(), + }; + return true; + }, + }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); app = modulesFixture.createNestApplication(); await app.init(); @@ -536,4 +563,79 @@ describe('TicketController', () => { expect(getHistoryUseCase.execute).toHaveBeenCalledTimes(1); 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, // consistente com o JwtStrategy + }; + return true; + }, + }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + const isolatedApp = moduleFixture.createNestApplication(); + await isolatedApp.init(); + + 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(isolatedApp.getHttpServer()) + .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, + }); + + 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 d98e0fb..2811ac3 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Body, Controller, @@ -6,6 +9,8 @@ import { Param, Post, Put, + Request, + UseGuards, Query, } from '@nestjs/common'; import { CreateTicketUseCase } from '../../application/useCases/create/create.usecase'; @@ -22,9 +27,9 @@ import { CloseTicketUseCase } from '../../application/useCases/close/close.useca import { CreateTicketRequest } from '../dtos/create.dto'; import { EscalateTicketRequest } from '../dtos/escalateTicket.dto'; import { TicketMapper } from '../mappers/ticket.mapper'; -import { AssignAgentRequest } from '../dtos/assignAgent.dto'; import { CloseTicketRequest } from '../dtos/closeTicket.dto'; import { + ApiBearerAuth, ApiBody, ApiOperation, ApiParam, @@ -32,12 +37,17 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; +import { JwtGuard } from '../../../auth/guards/jwt.guard'; +import { RolesGuard } from '../../../auth/guards/roles.guard'; +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'; @ApiTags('Ticket') @Controller('tickets') +@ApiBearerAuth() export class TicketController { constructor( private readonly createUseCase: CreateTicketUseCase, @@ -54,23 +64,31 @@ export class TicketController { @Post() @ApiOperation({ summary: 'Cria um ticket' }) @ApiBody({ type: CreateTicketRequest }) + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.CLIENT) @ApiResponse({ status: 201, description: 'Ticket criado com sucesso.' }) - async create(@Body() body: CreateTicketRequest) { + async create(@Request() req: any, @Body() body: CreateTicketRequest) { const data = TicketMapper.toCreateInput(body); - const response = await this.createUseCase.execute(data); + const response = await this.createUseCase.execute(data, req.user.id); return response; } @Get() @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() { - const response = await this.readAllUseCase.execute(); + async getAll(@Request() req: any) { + const response = await this.readAllUseCase.execute({ + userId: req.user.id, + categories: req.user.categories ?? undefined, + role: req.user.role, + }); return response; } @@ -78,6 +96,8 @@ export class TicketController { @Get(':id') @ApiOperation({ summary: 'Retorna um ticket pelo ID' }) @ApiParam({ name: 'id', example: 'uuid-do-ticket' }) + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPPORT, UserRole.CLIENT) @ApiResponse({ status: 200, description: 'Ticket encontrado com sucesso.' }) async getById(@Param('id') id: string) { const response = await this.readByIdUseCase.execute(id); @@ -97,6 +117,8 @@ export class TicketController { required: false, example: '2024-01-01T00:00:00.000Z', }) + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPPORT, UserRole.CLIENT) @ApiResponse({ status: 200, description: 'Histórico retornado com sucesso.' }) async getHistoryById( @Param('id') id: string, @@ -128,6 +150,8 @@ export class TicketController { @ApiOperation({ summary: 'Escalona um ticket' }) @ApiParam({ name: 'id', example: 'uuid-do-ticket' }) @ApiBody({ type: EscalateTicketRequest }) + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPPORT) @ApiResponse({ status: 200, description: 'Ticket escalonado com sucesso.' }) async escalateTicket( @Param('id') id: string, @@ -143,10 +167,11 @@ export class TicketController { @Put(':id/assignAgent') @ApiOperation({ summary: 'Atribui um agente ao ticket' }) @ApiParam({ name: 'id', example: 'uuid-do-ticket' }) - @ApiBody({ type: AssignAgentRequest }) + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPPORT) @ApiResponse({ status: 200, description: 'Agente atribuído com sucesso.' }) - async assignAgent(@Param('id') id: string, @Body() body: AssignAgentRequest) { - const data = TicketMapper.toNewAgentInput(id, body); + async assignAgent(@Request() req: any, @Param('id') id: string) { + const data = TicketMapper.toNewAgentInput(id, req.user.id); const response = await this.newAgentUseCase.execute(data); @@ -156,8 +181,10 @@ export class TicketController { @Delete(':id') @ApiOperation({ summary: 'Remove um ticket' }) @ApiParam({ name: 'id', example: 'uuid-do-ticket' }) + @UseGuards(JwtGuard, RolesGuard) + @Roles(UserRole.ADMIN) @ApiResponse({ status: 200, description: 'Ticket removido com sucesso.' }) - async delete(@Param() id: string) { + async delete(@Param('id') id: string) { const response = await this.deleteUseCase.execute(id); return { deleted: response }; diff --git a/backend/src/modules/ticket/presentation/dtos/assignAgent.dto.ts b/backend/src/modules/ticket/presentation/dtos/assignAgent.dto.ts deleted file mode 100644 index c6e9ade..0000000 --- a/backend/src/modules/ticket/presentation/dtos/assignAgent.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IsString, IsMongoId } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; -import { randomUUID } from 'crypto'; - -export class AssignAgentRequest { - @ApiProperty({ example: randomUUID(), description: 'ID do agente' }) - @IsMongoId() - @IsString() - agentId!: string; -} diff --git a/backend/src/modules/ticket/presentation/dtos/create.dto.ts b/backend/src/modules/ticket/presentation/dtos/create.dto.ts index 4a2cd2a..8337b34 100644 --- a/backend/src/modules/ticket/presentation/dtos/create.dto.ts +++ b/backend/src/modules/ticket/presentation/dtos/create.dto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class CreateTicketRequest { @@ -13,11 +13,6 @@ export class CreateTicketRequest { @IsString() description!: string; - @ApiProperty({ example: 'uuid-do-cliente', description: 'ID do cliente' }) - @IsString() - @IsNotEmpty() - clientId!: string; - @ApiProperty({ example: 1, description: 'Nível do chamado (1 a 3)' }) @IsOptional() @IsNumber() diff --git a/backend/src/modules/ticket/presentation/dtos/escalateTicket.dto.ts b/backend/src/modules/ticket/presentation/dtos/escalateTicket.dto.ts index 218a430..b242ae1 100644 --- a/backend/src/modules/ticket/presentation/dtos/escalateTicket.dto.ts +++ b/backend/src/modules/ticket/presentation/dtos/escalateTicket.dto.ts @@ -13,9 +13,10 @@ export class EscalateTicketRequest { @IsNotEmpty() category!: string; - @ApiProperty({ + @ApiProperty({ example: 'Reiniciei o servidor e o problema persistiu.', - description: 'O que foi feito antes de escalonar' }) + description: 'O que foi feito antes de escalonar', + }) @IsString() @IsNotEmpty() whatWasDone!: string; diff --git a/backend/src/modules/ticket/presentation/mappers/ticket.mapper.ts b/backend/src/modules/ticket/presentation/mappers/ticket.mapper.ts index 5bfd763..684efe3 100644 --- a/backend/src/modules/ticket/presentation/mappers/ticket.mapper.ts +++ b/backend/src/modules/ticket/presentation/mappers/ticket.mapper.ts @@ -1,7 +1,6 @@ import { CreateTicketInput } from '../../application/useCases/create/create.usecase'; import { EscalateTicketInput } from '../../application/useCases/escalate/escalate.usecase'; import { NewAgentTicketInput } from '../../application/useCases/newAgent/newAgent.usecase'; -import { AssignAgentRequest } from '../dtos/assignAgent.dto'; import { CloseTicketRequest } from '../dtos/closeTicket.dto'; import { CreateTicketRequest } from '../dtos/create.dto'; import { EscalateTicketRequest } from '../dtos/escalateTicket.dto'; @@ -11,18 +10,14 @@ export class TicketMapper { return { title: req.title, description: req.description, - clientId: req.clientId, - level: req.level + level: req.level, }; } - static toNewAgentInput( - id: string, - req: AssignAgentRequest, - ): NewAgentTicketInput { + static toNewAgentInput(id: string, agentId: string): NewAgentTicketInput { return { id: id, - agentId: req.agentId, + agentId: agentId, }; } @@ -34,7 +29,7 @@ export class TicketMapper { id: id, groupId: req.groupId, category: req.category, - whatWasDone: req.whatWasDone + whatWasDone: req.whatWasDone, }; } diff --git a/backend/src/modules/user/dtos/changeRoleUserDTO.ts b/backend/src/modules/user/dtos/changeRoleUserDTO.ts index c21781b..3e9e751 100644 --- a/backend/src/modules/user/dtos/changeRoleUserDTO.ts +++ b/backend/src/modules/user/dtos/changeRoleUserDTO.ts @@ -1,6 +1,6 @@ import { IsEnum } from 'class-validator'; -import { UserRole } from '../user.schema'; import { ApiProperty } from '@nestjs/swagger'; +import { UserRole } from '../../shared/enums/user.enum'; export class ChangeRoleUserDTO { @ApiProperty({ enum: UserRole, example: UserRole.ADMIN }) diff --git a/backend/src/modules/user/dtos/createUserDTO.ts b/backend/src/modules/user/dtos/createUserDTO.ts index 2707bdc..48aa130 100644 --- a/backend/src/modules/user/dtos/createUserDTO.ts +++ b/backend/src/modules/user/dtos/createUserDTO.ts @@ -10,8 +10,8 @@ import { Min, Max, } from 'class-validator'; -import { UserRole } from '../user.schema'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { UserRole } from '../../shared/enums/user.enum'; export class CreateUserDTO { @ApiProperty({ example: 'Gabriel' }) diff --git a/backend/src/modules/user/dtos/filterUserDTO.ts b/backend/src/modules/user/dtos/filterUserDTO.ts index 4cb288a..2cdc11f 100644 --- a/backend/src/modules/user/dtos/filterUserDTO.ts +++ b/backend/src/modules/user/dtos/filterUserDTO.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsNumberString, IsOptional, IsString } from 'class-validator'; -import { UserRole } from '../user.schema'; +import { UserRole } from '../../shared/enums/user.enum'; export class FilterUserDTO { @ApiPropertyOptional({ example: 'Gabriel' }) diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index ef41972..0199ad9 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -16,7 +16,6 @@ import { FilterUserDTO } from './dtos/filterUserDTO'; import { Roles } from '../auth/guards/roles.decorator'; import { JwtGuard } from '../auth/guards/jwt.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; -import { UserRole } from './user.schema'; import { ChangeRoleUserDTO } from './dtos/changeRoleUserDTO'; import { ChangeCategoriesUserDTO } from './dtos/changeCategoriesUserDTO'; import { @@ -28,6 +27,7 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; +import { UserRole } from '../shared/enums/user.enum'; @ApiTags('User') @ApiBearerAuth() diff --git a/backend/src/modules/user/user.interface.ts b/backend/src/modules/user/user.interface.ts index b5fd0db..5e0919f 100644 --- a/backend/src/modules/user/user.interface.ts +++ b/backend/src/modules/user/user.interface.ts @@ -1,6 +1,6 @@ import { CategoryDetails } from '../category/category.interface'; import { CompanyDetails } from '../company/company.interface'; -import { UserRole } from './user.schema'; +import { UserRole } from '../shared/enums/user.enum'; export interface UserDetails { id: string; diff --git a/backend/src/modules/user/user.schema.ts b/backend/src/modules/user/user.schema.ts index be50174..b4b2576 100644 --- a/backend/src/modules/user/user.schema.ts +++ b/backend/src/modules/user/user.schema.ts @@ -2,15 +2,10 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Document, Types } from 'mongoose'; import { Company } from '../company/company.schema'; import { Category } from '../category/category.schema'; +import { UserRole } from '../shared/enums/user.enum'; export type UserDocument = User & Document; -export enum UserRole { - CLIENT = 'client', - SUPPORT = 'support', - ADMIN = 'admin', -} - @Schema({ timestamps: { createdAt: true, updatedAt: false } }) export class User { @Prop({ required: true }) @@ -23,6 +18,7 @@ export class User { password: string; @Prop({ + type: String, required: true, enum: UserRole, default: UserRole.CLIENT, @@ -31,7 +27,7 @@ export class User { @Prop({ required: false, - default: 1 + default: 1, }) level: number; @@ -55,4 +51,4 @@ export class User { profileImage?: string; } -export const UserSchema = SchemaFactory.createForClass(User); \ No newline at end of file +export const UserSchema = SchemaFactory.createForClass(User); diff --git a/backend/src/modules/user/user.service.spec.ts b/backend/src/modules/user/user.service.spec.ts index a96fa1e..5abc6a7 100644 --- a/backend/src/modules/user/user.service.spec.ts +++ b/backend/src/modules/user/user.service.spec.ts @@ -4,11 +4,12 @@ import { Connection, Types } from 'mongoose'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { UserService } from './user.service'; -import { UserSchema, UserRole } from './user.schema'; +import { UserSchema } from './user.schema'; import { CompanyService } from '../company/company.service'; import { CategoryService } from '../category/category.service'; import { CompanySchema } from '../company/company.schema'; import { CategorySchema } from '../category/category.schema'; +import { UserRole } from '../shared/enums/user.enum'; describe('UserService (Integration)', () => { let service: UserService; diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 7db32d3..b20d6c3 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -5,10 +5,11 @@ import { } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; -import { UserDocument, UserRole } from './user.schema'; +import { UserDocument } from './user.schema'; import { UserDetails } from './user.interface'; import { CompanyService } from '../company/company.service'; import { CategoryService } from '../category/category.service'; +import { UserRole } from '../shared/enums/user.enum'; @Injectable() export class UserService {