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 bdc7e78..d8abda0 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 1809393..b1f01db 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts @@ -43,7 +43,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') @@ -59,7 +62,7 @@ export class TicketController { private readonly newAgentUseCase: NewAgentTicketUseCase, private readonly deleteUseCase: DeleteTicketUseCase, private readonly closeUseCase: CloseTicketUseCase, - ) { } + ) {} @Post() @ApiOperation({ summary: 'Cria um ticket' }) @@ -83,7 +86,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, @@ -91,7 +97,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', }); @@ -200,10 +208,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);