From 8abfacf7b45e9a5a90bc1e2f7933cef3cc303f9c Mon Sep 17 00:00:00 2001 From: Dimitri Date: Mon, 21 Sep 2026 12:33:09 -0300 Subject: [PATCH 01/16] feat: interfaces detalhadas para expedicao, participante, e rota --- src/domain/expedicao/Expedicao.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/domain/expedicao/Expedicao.ts b/src/domain/expedicao/Expedicao.ts index 1c99f99e..7c3966c9 100644 --- a/src/domain/expedicao/Expedicao.ts +++ b/src/domain/expedicao/Expedicao.ts @@ -12,6 +12,29 @@ export interface Attributes { updated_by: number | null } +export interface ExpedicaoListItem extends Attributes { + participantes: number[] + rotas: number[] +} + +export interface ParticipanteDetalhado { + id: number + nome: string + email: string +} + +export interface RotaDetalhada { + cidade_id: number + ordem: number + nome_cidade: string + estado: string +} + +export interface ExpedicaoDetalhada extends Attributes { + participantes: ParticipanteDetalhado[] + rotas: RotaDetalhada[] +} + export type CreateAttributes = Omit & { From 1e33c0d2f6ede1098fc4af613fe6d654a83e0895 Mon Sep 17 00:00:00 2001 From: Dimitri Date: Mon, 21 Sep 2026 12:58:07 -0300 Subject: [PATCH 02/16] feat: adiciona contratos de participantes e rotas em ExpedicaoCollection --- src/domain/expedicao/ExpedicaoCollection.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/domain/expedicao/ExpedicaoCollection.ts b/src/domain/expedicao/ExpedicaoCollection.ts index fbeaa0a4..4cd6c818 100644 --- a/src/domain/expedicao/ExpedicaoCollection.ts +++ b/src/domain/expedicao/ExpedicaoCollection.ts @@ -1,6 +1,6 @@ import { Either } from '@/library/either/Either' -import { Attributes, CreateAttributes } from './Expedicao' +import { Attributes, CreateAttributes, ExpedicaoListItem, ExpedicaoDetalhada } from './Expedicao' export interface ExpedicaoOrder { column: 'id' | 'data_inicio' | 'data_fim' @@ -8,6 +8,8 @@ export interface ExpedicaoOrder { } export interface ExpedicaoFilters { + pagina?: number + limite?: number cidade_id?: number usuario_id?: number data_inicio_de?: string @@ -15,8 +17,21 @@ export interface ExpedicaoFilters { order?: ExpedicaoOrder } +export interface PaginatedResult { + items: T[] + total: number + pagina: number + limite: number +} + export interface ExpedicaoCollection { - findAll(filters: ExpedicaoFilters): Promise> - findById(id: number): Promise> + findAll(filters: ExpedicaoFilters): Promise>> + findById(id: number): Promise> create(attributes: CreateAttributes): Promise> + + addParticipant(expedicaoId: number, usuarioId: number): Promise> + removeParticipant(expedicaoId: number, usuarioId: number): Promise> + + substituteRoute(expedicaoId: number, rotas: number[]): Promise> + } From f700fdee955c22421c5ac02e878c8a1e3eb5c3c5 Mon Sep 17 00:00:00 2001 From: Dimitri Date: Tue, 22 Sep 2026 20:53:36 -0300 Subject: [PATCH 03/16] refactor: update nas interfaces de ExpedicaoCollection por consistencia --- src/domain/expedicao/ExpedicaoCollection.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/domain/expedicao/ExpedicaoCollection.ts b/src/domain/expedicao/ExpedicaoCollection.ts index 4cd6c818..2147bb04 100644 --- a/src/domain/expedicao/ExpedicaoCollection.ts +++ b/src/domain/expedicao/ExpedicaoCollection.ts @@ -1,6 +1,6 @@ import { Either } from '@/library/either/Either' -import { Attributes, CreateAttributes, ExpedicaoListItem, ExpedicaoDetalhada } from './Expedicao' +import { Attributes, CreateAttributes } from './Expedicao' export interface ExpedicaoOrder { column: 'id' | 'data_inicio' | 'data_fim' @@ -17,21 +17,25 @@ export interface ExpedicaoFilters { order?: ExpedicaoOrder } -export interface PaginatedResult { - items: T[] +export interface Paginated { + itens: T[] total: number - pagina: number limite: number + pagina: number +} +export interface ExpedicaoListItem extends Attributes { + participantes: number[] + rotas: number[] } export interface ExpedicaoCollection { - findAll(filters: ExpedicaoFilters): Promise>> - findById(id: number): Promise> + findAll(filters: ExpedicaoFilters): Promise>> + findById(id: number): Promise> create(attributes: CreateAttributes): Promise> addParticipant(expedicaoId: number, usuarioId: number): Promise> removeParticipant(expedicaoId: number, usuarioId: number): Promise> substituteRoute(expedicaoId: number, rotas: number[]): Promise> - + } From cb332b9f1ddddbc711c25f4b4e5ae33d660645e0 Mon Sep 17 00:00:00 2001 From: Dimitri Date: Tue, 22 Sep 2026 21:01:13 -0300 Subject: [PATCH 04/16] feat: adiciona use cases para adicionar e remover participantes, e, substituir rota --- .../expedicao/AdcionaParticipanteUseCase.ts | 18 ++++++++++++++++++ .../expedicao/RemoveParticipanteUseCase.ts | 18 ++++++++++++++++++ src/domain/expedicao/SubstituiRotaUseCase.ts | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 src/domain/expedicao/AdcionaParticipanteUseCase.ts create mode 100644 src/domain/expedicao/RemoveParticipanteUseCase.ts create mode 100644 src/domain/expedicao/SubstituiRotaUseCase.ts diff --git a/src/domain/expedicao/AdcionaParticipanteUseCase.ts b/src/domain/expedicao/AdcionaParticipanteUseCase.ts new file mode 100644 index 00000000..28d10532 --- /dev/null +++ b/src/domain/expedicao/AdcionaParticipanteUseCase.ts @@ -0,0 +1,18 @@ +import { Either } from '@/library/either/Either' +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class AdicionaParticipanteUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + execute(expedicaoId: number, usuarioId: number): Promise> { + return this.expedicaoCollection.addParticipant(expedicaoId, usuarioId) + } +} diff --git a/src/domain/expedicao/RemoveParticipanteUseCase.ts b/src/domain/expedicao/RemoveParticipanteUseCase.ts new file mode 100644 index 00000000..7668986f --- /dev/null +++ b/src/domain/expedicao/RemoveParticipanteUseCase.ts @@ -0,0 +1,18 @@ +import { Either } from '@/library/either/Either' +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class RemoveParticipanteUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + execute(expedicaoId: number, usuarioId: number): Promise> { + return this.expedicaoCollection.removeParticipant(expedicaoId, usuarioId) + } +} diff --git a/src/domain/expedicao/SubstituiRotaUseCase.ts b/src/domain/expedicao/SubstituiRotaUseCase.ts new file mode 100644 index 00000000..a985d64a --- /dev/null +++ b/src/domain/expedicao/SubstituiRotaUseCase.ts @@ -0,0 +1,18 @@ +import { Either } from '@/library/either/Either' +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class SubstituiRotasUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + execute(expedicaoId: number, rotas: number[]): Promise> { + return this.expedicaoCollection.substituteRoute(expedicaoId, rotas) + } +} From c8c09e7eaba99e4281b1e4f393b907749119adce Mon Sep 17 00:00:00 2001 From: Dimitri Date: Tue, 22 Sep 2026 21:02:23 -0300 Subject: [PATCH 05/16] feat: adciona ConflictError para http 409 --- src/library/http/error/ConflictError.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/library/http/error/ConflictError.ts diff --git a/src/library/http/error/ConflictError.ts b/src/library/http/error/ConflictError.ts new file mode 100644 index 00000000..e9c3faa5 --- /dev/null +++ b/src/library/http/error/ConflictError.ts @@ -0,0 +1,7 @@ +import { HttpError } from "./HttpError"; + +export class ConflictError extends HttpError { + constructor(params: { message: string; report?: unknown; cause?: unknown }) { + super({ ...params, statusCode: 409 }); + } +} From 7de22801b2fd3a049220a75303bed4a242e6fedf Mon Sep 17 00:00:00 2001 From: Dimitri Date: Tue, 22 Sep 2026 21:13:54 -0300 Subject: [PATCH 06/16] feat: adiciona factory para ExpedicaoCollection --- src/factory/ExpedicaoCollectionFactory.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/factory/ExpedicaoCollectionFactory.ts diff --git a/src/factory/ExpedicaoCollectionFactory.ts b/src/factory/ExpedicaoCollectionFactory.ts new file mode 100644 index 00000000..fe19e09e --- /dev/null +++ b/src/factory/ExpedicaoCollectionFactory.ts @@ -0,0 +1,8 @@ +import { ExpedicaoCollectionKnexAdapter } from '@/infrastructure/ExpedicaoCollectionKnexAdapter' +import { singleton } from '@/library/singleton' + +import { createKnexInstance } from './KnexFactory' + +export const createExpedicaoCollection = singleton(() => { + return new ExpedicaoCollectionKnexAdapter({ knex: createKnexInstance() }) +}) From b2daceffd5c9c7ec7f1c1ed40a3adade57d66e20 Mon Sep 17 00:00:00 2001 From: Dimitri Date: Tue, 22 Sep 2026 21:16:00 -0300 Subject: [PATCH 07/16] feat: adiciona controllers e rotas para participantes e rotas --- .../AdicionaParticipanteController.ts | 67 +++++++++++++++++++ .../expedicao/RemoveParticipanteController.ts | 56 ++++++++++++++++ .../expedicao/SubstituirRotaController.ts | 57 ++++++++++++++++ src/application/expedicao/index.ts | 45 +++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 src/application/expedicao/AdicionaParticipanteController.ts create mode 100644 src/application/expedicao/RemoveParticipanteController.ts create mode 100644 src/application/expedicao/SubstituirRotaController.ts create mode 100644 src/application/expedicao/index.ts diff --git a/src/application/expedicao/AdicionaParticipanteController.ts b/src/application/expedicao/AdicionaParticipanteController.ts new file mode 100644 index 00000000..55198a45 --- /dev/null +++ b/src/application/expedicao/AdicionaParticipanteController.ts @@ -0,0 +1,67 @@ +import { AdicionaParticipanteUseCase } from '@/domain/expedicao/AdcionaParticipanteUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { ConflictError } from '@/library/http/error/ConflictError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + adicionaParticipanteUseCase: AdicionaParticipanteUseCase +} + +interface CustomHttpRequest extends HttpRequest { + params: Record + body: any + usuario?: { + id: number + tipo_usuario_id: number + } +} + +export class AdicionaParticipanteController implements RequestHandler { + private readonly adicionaParticipanteUseCase: AdicionaParticipanteUseCase + + constructor(dependencies: Dependencies) { + this.adicionaParticipanteUseCase = dependencies.adicionaParticipanteUseCase + } + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + // TODO: AUTENTICAÇÃO TEMPORARIAMENTE DESABILITADA + + const { expedicaoId } = request.params + const { usuarioId } = request.body as { usuarioId?: number } + if (expedicaoId === undefined || expedicaoId === null || expedicaoId === '' || !/^\d+$/.test(expedicaoId)) { + return new BadRequestError({ message: 'expedicaoId inválido' }) + } + + if (usuarioId === undefined || typeof usuarioId !== 'number' || usuarioId <= 0) { + return new BadRequestError({ message: 'usuarioId inválido' }) + } + + const result = await this.adicionaParticipanteUseCase.execute(Number(expedicaoId), usuarioId) + if (result.left()) { + if (result.value.message.includes('já está nesta expedição')) { + return new ConflictError({ message: result.value.message }) + } + + if (result.value.name === 'CollectionError' || result.value.message.includes('Falha ao adicionar')) { + return new InternalServerError({ message: 'Falha interna ao adicionar participante' }) + } + + return new BadRequestError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.Created, + body: { message: 'Participante adicionado com sucesso.' } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao adicionar participante' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/RemoveParticipanteController.ts b/src/application/expedicao/RemoveParticipanteController.ts new file mode 100644 index 00000000..8cbdced0 --- /dev/null +++ b/src/application/expedicao/RemoveParticipanteController.ts @@ -0,0 +1,56 @@ +import { RemoveParticipanteUseCase, } from '@/domain/expedicao/RemoveParticipanteUseCase' +import { HttpRequest, HttpResponse, StatusCode } from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + removeParticipanteUseCase: RemoveParticipanteUseCase +} + +interface CustomHttpRequest extends HttpRequest { + usuario?: { + id: number + tipo_usuario_id: number + } +} + +export class RemoveParticipanteController implements RequestHandler { + private readonly removeParticipanteUseCase: RemoveParticipanteUseCase + + constructor(dependencies: Dependencies) { + this.removeParticipanteUseCase = dependencies.removeParticipanteUseCase + } + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + const { expedicaoId, usuarioId } = request.params + + if (expedicaoId === undefined || expedicaoId === null || expedicaoId === '' || typeof expedicaoId !== 'string' || !/^\d+$/.test(expedicaoId)) { + return new BadRequestError({ message: 'expedicaoId inválido' }) + } + + if (usuarioId === undefined || usuarioId === null || usuarioId === '' || typeof usuarioId !== 'string' || !/^\d+$/.test(usuarioId)) { + return new BadRequestError({ message: 'usuarioId inválido' }) + } + + const result = await this.removeParticipanteUseCase.execute(Number(expedicaoId), Number(usuarioId)) + + if (result.left()) { + if (result.value.name === 'CollectionError' || result.value.message.includes('Falha')) { + return new InternalServerError({ message: 'Falha interna ao remover participante' }) + } + return new BadRequestError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.NoContent, + body: undefined + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao remover participante' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/SubstituirRotaController.ts b/src/application/expedicao/SubstituirRotaController.ts new file mode 100644 index 00000000..d0ba6cc7 --- /dev/null +++ b/src/application/expedicao/SubstituirRotaController.ts @@ -0,0 +1,57 @@ +import { SubstituiRotasUseCase } from '@/domain/expedicao/SubstituiRotaUseCase' +import { HttpRequest, HttpResponse, StatusCode } from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + SubstituiRotasUseCase: SubstituiRotasUseCase +} + +interface CustomHttpRequest extends HttpRequest { + usuario?: { + id: number + tipo_usuario_id: number + } +} + +export class SubstituiRotasController implements RequestHandler { + private readonly SubstituiRotasUseCase: SubstituiRotasUseCase + + constructor(dependencies: Dependencies) { + this.SubstituiRotasUseCase = dependencies.SubstituiRotasUseCase + } + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + const { expedicaoId } = request.params + const { rotas } = request.body as { rotas?: number[] } + + if (expedicaoId === undefined || expedicaoId === null || expedicaoId === '' || typeof expedicaoId !== 'string' || !/^\d+$/.test(expedicaoId)) { + return new BadRequestError({ message: 'expedicaoId inválido' }) + } + + if (!rotas || !Array.isArray(rotas) || rotas.some(id => typeof id !== 'number' || id <= 0)) { + return new BadRequestError({ message: 'rotas inválidas' }) + } + + const result = await this.SubstituiRotasUseCase.execute(Number(expedicaoId), rotas) + + if (result.left()) { + if (result.value.name === 'CollectionError' || result.value.message.includes('Falha')) { + return new InternalServerError({ message: 'Falha interna ao substituir rotas' }) + } + return new BadRequestError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.NoContent, + body: undefined + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao substituir rotas' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/index.ts b/src/application/expedicao/index.ts new file mode 100644 index 00000000..7ae8b9d5 --- /dev/null +++ b/src/application/expedicao/index.ts @@ -0,0 +1,45 @@ +import { type Knex } from 'knex' +import { Method } from '@/library/http/common' +import { Route } from '@/library/http/Router' +import { createExpedicaoCollection } from '@/factory/ExpedicaoCollectionFactory' +import { AdicionaParticipanteUseCase } from '@/domain/expedicao/AdcionaParticipanteUseCase' +import { AdicionaParticipanteController } from './AdicionaParticipanteController' +import { RemoveParticipanteUseCase } from '@/domain/expedicao/RemoveParticipanteUseCase' +import { RemoveParticipanteController } from './RemoveParticipanteController' +import { SubstituiRotasController } from './SubstituirRotaController' +import { SubstituiRotasUseCase } from '@/domain/expedicao/SubstituiRotaUseCase' + + +export function routes(knex: Knex): Route[] { + const expedicaoCollection = createExpedicaoCollection() + + return [ + { + method: Method.Post, + path: '/v2/expedicoes/:expedicaoId/participantes', + handlers: [ + new AdicionaParticipanteController({ + adicionaParticipanteUseCase: new AdicionaParticipanteUseCase({ expedicaoCollection }) + }) + ] + }, + { + method: Method.Delete, + path: '/v2/expedicoes/:expedicaoId/participantes/:usuarioId', + handlers: [ + new RemoveParticipanteController({ + removeParticipanteUseCase: new RemoveParticipanteUseCase({ expedicaoCollection }) + }) + ] + }, + { + method: Method.Put, + path: '/v2/expedicoes/:expedicaoId/rotas', + handlers: [ + new SubstituiRotasController({ + SubstituiRotasUseCase: new SubstituiRotasUseCase({ expedicaoCollection }) + }) + ] + } + ] +} From 8ee9b9bf59d54e3c37d9528e0a827ab976877957 Mon Sep 17 00:00:00 2001 From: Dimitri Date: Tue, 22 Sep 2026 21:18:25 -0300 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20adciona=20metodos=20para=20partic?= =?UTF-8?q?ipantes=20e=20rotas,=20e,=20ajusta=20findId=20e=20findAll=20por?= =?UTF-8?q?=20consistencia=20para=20branch=20irm=C3=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExpedicaoCollectionKnexAdapter.ts | 153 ++++++++++++++++-- 1 file changed, 139 insertions(+), 14 deletions(-) diff --git a/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts b/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts index 85fcf963..4c94953c 100644 --- a/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts +++ b/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts @@ -1,7 +1,11 @@ import { Knex } from 'knex' -import { Attributes, CreateAttributes } from '@/domain/expedicao/Expedicao' -import { ExpedicaoCollection, ExpedicaoFilters } from '@/domain/expedicao/ExpedicaoCollection' +import { + Attributes, CreateAttributes +} from '@/domain/expedicao/Expedicao' +import { + ExpedicaoCollection, ExpedicaoFilters, ExpedicaoListItem, Paginated +} from '@/domain/expedicao/ExpedicaoCollection' import { Either } from '@/library/either/Either' import { CollectionError } from './error/CollectionError' @@ -59,13 +63,11 @@ export class ExpedicaoCollectionKnexAdapter implements ExpedicaoCollection { ]) } - async findAll(filters: ExpedicaoFilters): Promise> { + async findAll(filters: ExpedicaoFilters): Promise>> { try { const query = this.select() - if (filters.cidade_id) { - query.where('expedicoes.cidade_id', filters.cidade_id) - } + if (filters.cidade_id) query.where('expedicoes.cidade_id', filters.cidade_id) if (filters.usuario_id) { query.whereIn('expedicoes.id', this.knex('expedicoes_participantes') @@ -73,19 +75,66 @@ export class ExpedicaoCollectionKnexAdapter implements ExpedicaoCollection { .where('usuario_id', filters.usuario_id)) } - if (filters.data_inicio_de) { - query.where('expedicoes.data_inicio', '>=', filters.data_inicio_de) - } + if (filters.data_inicio_de) query.where('expedicoes.data_inicio', '>=', filters.data_inicio_de) + if (filters.data_fim_ate) query.where('expedicoes.data_fim', '<=', filters.data_fim_ate) - if (filters.data_fim_ate) { - query.where('expedicoes.data_fim', '<=', filters.data_fim_ate) - } + // contagem de total de registros + const countQuery = query.clone() + const [result] = await countQuery.clearSelect().count<{ count: string | number }[]>('* as count') + const count = result?.count ?? 0 + const total = Number(count) + + // valores padrão da paginação(20 e 1) + const limite = filters.limite && filters.limite > 0 ? filters.limite : 20 + const pagina = filters.pagina && filters.pagina > 0 ? filters.pagina : 1 + const offset = (pagina - 1) * limite + + query.limit(limite).offset(offset) + // ordenação const order = filters.order ?? { column: 'id' as const, direction: 'desc' as const } query.orderBy(`expedicoes.${order.column}`, order.direction) const rows = await query as Row[] - return Either.right(rows.map(toAttributes)) + + // página vazia, já retorna + if (rows.length === 0) { + return Either.right({ + itens: [], total, limite, pagina + }) + } + + const expedicoesIds = rows.map(row => row.id) + + const [participantesRows, rotasRows] = await Promise.all([ + this.knex<{ expedicao_id: number; usuario_id: number }>('expedicoes_participantes') + .select('expedicao_id', 'usuario_id') + .whereIn('expedicao_id', expedicoesIds), + + this.knex<{ expedicao_id: number; cidade_id: number }>('expedicoes_rotas') + .select('expedicao_id', 'cidade_id') + .whereIn('expedicao_id', expedicoesIds) + .orderBy('ordem', 'asc') + ]) + + const itens = rows.map(row => { + return { + ...toAttributes(row), + participantes: participantesRows + .filter(p => p.expedicao_id === row.id) + .map(p => p.usuario_id), + rotas: rotasRows + .filter(r => r.expedicao_id === row.id) + .map(r => r.cidade_id) + } + }) + + return Either.right({ + itens, + total, + limite, + pagina + }) } catch (error) { return Either.left(new CollectionError({ message: 'Failed to list expedições', cause: error })) } @@ -94,7 +143,32 @@ export class ExpedicaoCollectionKnexAdapter implements ExpedicaoCollection { async findById(id: number): Promise> { try { const row = await this.select().where('expedicoes.id', id).first() as Row | undefined - return Either.right(row ? toAttributes(row) : null) + + if (!row) { + return Either.right(null) + } + + const participantes = await this.knex('expedicoes_participantes') + .join('usuarios', 'usuarios.id', 'expedicoes_participantes.usuario_id') + .where('expedicoes_participantes.expedicao_id', id) + .select('usuarios.id', 'usuarios.nome', 'usuarios.email') + + const rotas = await this.knex('expedicoes_rotas') + .join('cidades', 'cidades.id', 'expedicoes_rotas.cidade_id') + .where('expedicoes_rotas.expedicao_id', id) + .select( + 'cidades.id as cidade_id', + 'expedicoes_rotas.ordem', + 'cidades.nome as nome_cidade', + 'cidades.estado_id' + ) + .orderBy('expedicoes_rotas.ordem', 'asc') + + return Either.right({ + ...toAttributes(row), + participantes, + rotas + } as unknown as Attributes) } catch (error) { return Either.left(new CollectionError({ message: 'Failed to find expedição by id', cause: error })) } @@ -149,4 +223,55 @@ export class ExpedicaoCollectionKnexAdapter implements ExpedicaoCollection { return Either.left(new CollectionError({ message: 'Failed to create expedição', cause: error })) } } + + async addParticipant(expedicaoId: number, usuarioId: number): Promise> { + try { + await this.knex('expedicoes_participantes').insert({ + expedicao_id: expedicaoId, + usuario_id: usuarioId + }) + return Either.right(undefined) + } catch (error: any) { + //Código 23505 é a violação de constraint unique no Postgres + if (error.code === '23505') { + return Either.left(new Error('Participante já está nesta expedição.')) + } + return Either.left(new CollectionError({ message: 'Falha ao adicionar participante', cause: error })) + } + } + + async removeParticipant(expedicaoId: number, usuarioId: number): Promise> { + try { + await this.knex('expedicoes_participantes') + .where({ expedicao_id: expedicaoId, usuario_id: usuarioId }) + .delete() + return Either.right(undefined) + } catch (error) { + return Either.left(new CollectionError({ message: 'Falha ao remover participante', cause: error })) + } + } + + async substituteRoute(expedicaoId: number, rotas: number[]): Promise> { + try { + await this.knex.transaction(async (trx) => { + // usa uma transaction para garantir que a exclusão e a inserção ocorram sem a perda de dados em caso de falha. + // se qualquer operação falhar, a transação será revertida e nenhuma alteração será feita no banco de dados. + // devido a constraint unique, estamos deletando todas as rotas da expedição e inserindo novamente, + // já na ordem correta e seguindo a constraint. + await trx('expedicoes_rotas').where('expedicao_id', expedicaoId).delete() + + if (rotas.length > 0) { + const insertData = rotas.map((cidadeId, index) => ({ + expedicao_id: expedicaoId, + cidade_id: cidadeId, + ordem: index + })) + await trx('expedicoes_rotas').insert(insertData) + } + }) + return Either.right(undefined) + } catch (error) { + return Either.left(new CollectionError({ message: 'Falha ao substituir rotas', cause: error })) + } + } } From 6909a1561fe008964dd1b034af050d08c1c0bc6e Mon Sep 17 00:00:00 2001 From: Dimitri Date: Tue, 22 Sep 2026 21:19:56 -0300 Subject: [PATCH 09/16] test: adiciona testes de integracao para participantes e rotas --- ...expedicao-collection.participantes.test.ts | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 test/integration/expedicao/expedicao-collection.participantes.test.ts diff --git a/test/integration/expedicao/expedicao-collection.participantes.test.ts b/test/integration/expedicao/expedicao-collection.participantes.test.ts new file mode 100644 index 00000000..2a256fff --- /dev/null +++ b/test/integration/expedicao/expedicao-collection.participantes.test.ts @@ -0,0 +1,153 @@ +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { ExpedicaoCollectionKnexAdapter } from "@/infrastructure/ExpedicaoCollectionKnexAdapter"; + +import { createTestKnex } from "../setup/app-factory"; +import { + cleanupExpedicaoFixtures, + ExpedicaoFixtures, + seedExpedicaoFixtures, +} from "../setup/seeds/expedicao.seed"; + +describe("ExpedicaoCollectionKnexAdapter - Sub-recursos (Participantes e Rotas)", () => { + const knex = createTestKnex(); + const collection = new ExpedicaoCollectionKnexAdapter({ knex }); + + let fixtures: ExpedicaoFixtures; + + beforeAll(async () => { + fixtures = await seedExpedicaoFixtures(knex); + }); + + afterAll(async () => { + await cleanupExpedicaoFixtures(knex, fixtures); + await knex.destroy(); + }); + + async function criarExpedicaoParaTeste(prefixo: string) { + return collection.create({ + descricao: `Expedicao [${prefixo}] - Teste Isolado`, + data_inicio: "2026-04-01", + data_fim: "2026-04-10", + cidade_id: fixtures.cidades[0], + created_by: fixtures.usuarios[0], + participantes: [fixtures.usuarios[0]], + rotas: [fixtures.cidades[1]], + }); + } + + describe("Adicionar e Remover Participantes", () => { + test("addParticipant insere um novo usuário na expedição", async () => { + const created = await criarExpedicaoParaTeste("ADD_PART"); + expect(created.right()).toBe(true); + if (!created.right()) return; + const expedicaoId = created.value.id; + + try { + const novoParticipante = fixtures.usuarios[1]; + const result = await collection.addParticipant( + expedicaoId, + novoParticipante, + ); + expect(result.right()).toBe(true); + const banco = await knex("expedicoes_participantes") + .where({ expedicao_id: expedicaoId, usuario_id: novoParticipante }) + .first(); + + expect(banco).toBeDefined(); + expect(Number(banco.usuario_id)).toBe(novoParticipante); + } finally { + await knex("expedicoes").where({ id: expedicaoId }).delete(); + } + }); + + test("addParticipant falha ao tentar inserir usuário já existente (unique constraint)", async () => { + const created = await criarExpedicaoParaTeste("ADD_FAIL_UNIQUE"); + if (!created.right()) return; + const expedicaoId = created.value.id; + + try { + const participanteExistente = fixtures.usuarios[0]; + + const result = await collection.addParticipant( + expedicaoId, + participanteExistente, + ); + + expect(result.left()).toBe(true); + if (result.left()) { + expect(result.value.message).toContain("já está nesta expedição"); + } + } finally { + await knex("expedicoes").where({ id: expedicaoId }).delete(); + } + }); + + test("removeParticipant exclui o usuário da expedição", async () => { + const created = await criarExpedicaoParaTeste("REM_PART"); + if (!created.right()) return; + const expedicaoId = created.value.id; + + try { + const participanteParaRemover = fixtures.usuarios[0]; + + const result = await collection.removeParticipant( + expedicaoId, + participanteParaRemover, + ); + expect(result.right()).toBe(true); + + const banco = await knex("expedicoes_participantes") + .where({ + expedicao_id: expedicaoId, + usuario_id: participanteParaRemover, + }) + .first(); + + expect(banco).toBeUndefined(); + } finally { + await knex("expedicoes").where({ id: expedicaoId }).delete(); + } + }); + }); + + describe("Substituir Rotas", () => { + test("substituteRoute remove as antigas e insere as novas na ordem correta", async () => { + const created = await criarExpedicaoParaTeste("SUB_ROTAS"); + if (!created.right()) return; + const expedicaoId = created.value.id; + + try { + const novasRotas = [ + fixtures.cidades[2], + fixtures.cidades[0], + fixtures.cidades[2], + ]; + + const result = await collection.substituteRoute( + expedicaoId, + novasRotas, + ); + expect(result.right()).toBe(true); + + const rotasNoBanco = await knex("expedicoes_rotas") + .where({ expedicao_id: expedicaoId }) + .orderBy("ordem"); + + expect(rotasNoBanco.length).toBe(3); + + const arrayValidacao = rotasNoBanco.map((rota) => [ + rota.ordem, + Number(rota.cidade_id), + ]); + expect(arrayValidacao).toEqual([ + [0, novasRotas[0]], + [1, novasRotas[1]], + [2, novasRotas[2]], + ]); + } finally { + await knex("expedicoes").where({ id: expedicaoId }).delete(); + } + }); + }); +}); From b45e95f5d5417986ebb7b318cbc8f8da237ee61b Mon Sep 17 00:00:00 2001 From: Dimitri Date: Tue, 22 Sep 2026 21:22:09 -0300 Subject: [PATCH 10/16] test: atualiza teste para refletir interface de retorno paginado --- test/integration/expedicao/expedicao-collection.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/expedicao/expedicao-collection.test.ts b/test/integration/expedicao/expedicao-collection.test.ts index fc21cf95..d53f1bbb 100644 --- a/test/integration/expedicao/expedicao-collection.test.ts +++ b/test/integration/expedicao/expedicao-collection.test.ts @@ -122,7 +122,7 @@ describe('ExpedicaoCollectionKnexAdapter', () => { expect(found.right()).toBe(true) if (!found.right()) return - expect(found.value.map(expedicao => expedicao.id)).toEqual([comBruno.value.id]) + expect(found.value.itens.map(expedicao => expedicao.id)).toEqual([comBruno.value.id]) } finally { await knex('expedicoes').whereIn('id', [comAna.value.id, comBruno.value.id]).delete() } From cee01ead3df97fdfb895b9c2f97506647fbb6c0d Mon Sep 17 00:00:00 2001 From: Dimitri Date: Tue, 22 Sep 2026 21:51:27 -0300 Subject: [PATCH 11/16] fix: corrige formatacao e erros do lint --- .../AdicionaParticipanteController.ts | 2 +- .../expedicao/RemoveParticipanteController.ts | 6 +- .../expedicao/SubstituirRotaController.ts | 4 +- src/application/expedicao/index.ts | 11 +- .../expedicao/AdcionaParticipanteUseCase.ts | 1 + .../expedicao/RemoveParticipanteUseCase.ts | 1 + src/domain/expedicao/SubstituiRotaUseCase.ts | 1 + .../ExpedicaoCollectionKnexAdapter.ts | 15 +- src/library/http/error/ConflictError.ts | 4 +- ...expedicao-collection.participantes.test.ts | 166 +++++++++--------- 10 files changed, 111 insertions(+), 100 deletions(-) diff --git a/src/application/expedicao/AdicionaParticipanteController.ts b/src/application/expedicao/AdicionaParticipanteController.ts index 55198a45..b6198278 100644 --- a/src/application/expedicao/AdicionaParticipanteController.ts +++ b/src/application/expedicao/AdicionaParticipanteController.ts @@ -14,7 +14,7 @@ interface Dependencies { interface CustomHttpRequest extends HttpRequest { params: Record - body: any + body: unknown usuario?: { id: number tipo_usuario_id: number diff --git a/src/application/expedicao/RemoveParticipanteController.ts b/src/application/expedicao/RemoveParticipanteController.ts index 8cbdced0..726da6a4 100644 --- a/src/application/expedicao/RemoveParticipanteController.ts +++ b/src/application/expedicao/RemoveParticipanteController.ts @@ -1,5 +1,7 @@ -import { RemoveParticipanteUseCase, } from '@/domain/expedicao/RemoveParticipanteUseCase' -import { HttpRequest, HttpResponse, StatusCode } from '@/library/http/common' +import { RemoveParticipanteUseCase } from '@/domain/expedicao/RemoveParticipanteUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' import { BadRequestError } from '@/library/http/error/BadRequestError' import { HttpError } from '@/library/http/error/HttpError' import { InternalServerError } from '@/library/http/error/InternalServerError' diff --git a/src/application/expedicao/SubstituirRotaController.ts b/src/application/expedicao/SubstituirRotaController.ts index d0ba6cc7..3883b85c 100644 --- a/src/application/expedicao/SubstituirRotaController.ts +++ b/src/application/expedicao/SubstituirRotaController.ts @@ -1,5 +1,7 @@ import { SubstituiRotasUseCase } from '@/domain/expedicao/SubstituiRotaUseCase' -import { HttpRequest, HttpResponse, StatusCode } from '@/library/http/common' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' import { BadRequestError } from '@/library/http/error/BadRequestError' import { HttpError } from '@/library/http/error/HttpError' import { InternalServerError } from '@/library/http/error/InternalServerError' diff --git a/src/application/expedicao/index.ts b/src/application/expedicao/index.ts index 7ae8b9d5..84db0d64 100644 --- a/src/application/expedicao/index.ts +++ b/src/application/expedicao/index.ts @@ -1,14 +1,15 @@ import { type Knex } from 'knex' + +import { AdicionaParticipanteUseCase } from '@/domain/expedicao/AdcionaParticipanteUseCase' +import { RemoveParticipanteUseCase } from '@/domain/expedicao/RemoveParticipanteUseCase' +import { SubstituiRotasUseCase } from '@/domain/expedicao/SubstituiRotaUseCase' +import { createExpedicaoCollection } from '@/factory/ExpedicaoCollectionFactory' import { Method } from '@/library/http/common' import { Route } from '@/library/http/Router' -import { createExpedicaoCollection } from '@/factory/ExpedicaoCollectionFactory' -import { AdicionaParticipanteUseCase } from '@/domain/expedicao/AdcionaParticipanteUseCase' + import { AdicionaParticipanteController } from './AdicionaParticipanteController' -import { RemoveParticipanteUseCase } from '@/domain/expedicao/RemoveParticipanteUseCase' import { RemoveParticipanteController } from './RemoveParticipanteController' import { SubstituiRotasController } from './SubstituirRotaController' -import { SubstituiRotasUseCase } from '@/domain/expedicao/SubstituiRotaUseCase' - export function routes(knex: Knex): Route[] { const expedicaoCollection = createExpedicaoCollection() diff --git a/src/domain/expedicao/AdcionaParticipanteUseCase.ts b/src/domain/expedicao/AdcionaParticipanteUseCase.ts index 28d10532..caeb2e0c 100644 --- a/src/domain/expedicao/AdcionaParticipanteUseCase.ts +++ b/src/domain/expedicao/AdcionaParticipanteUseCase.ts @@ -1,4 +1,5 @@ import { Either } from '@/library/either/Either' + import { ExpedicaoCollection } from './ExpedicaoCollection' interface Dependencies { diff --git a/src/domain/expedicao/RemoveParticipanteUseCase.ts b/src/domain/expedicao/RemoveParticipanteUseCase.ts index 7668986f..c3b3bae5 100644 --- a/src/domain/expedicao/RemoveParticipanteUseCase.ts +++ b/src/domain/expedicao/RemoveParticipanteUseCase.ts @@ -1,4 +1,5 @@ import { Either } from '@/library/either/Either' + import { ExpedicaoCollection } from './ExpedicaoCollection' interface Dependencies { diff --git a/src/domain/expedicao/SubstituiRotaUseCase.ts b/src/domain/expedicao/SubstituiRotaUseCase.ts index a985d64a..df4df554 100644 --- a/src/domain/expedicao/SubstituiRotaUseCase.ts +++ b/src/domain/expedicao/SubstituiRotaUseCase.ts @@ -1,4 +1,5 @@ import { Either } from '@/library/either/Either' + import { ExpedicaoCollection } from './ExpedicaoCollection' interface Dependencies { diff --git a/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts b/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts index 4c94953c..b5c4ecb0 100644 --- a/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts +++ b/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts @@ -231,9 +231,10 @@ export class ExpedicaoCollectionKnexAdapter implements ExpedicaoCollection { usuario_id: usuarioId }) return Either.right(undefined) - } catch (error: any) { - //Código 23505 é a violação de constraint unique no Postgres - if (error.code === '23505') { + } catch (error: unknown) { + const dbError = error as { code?: string } + + if (dbError.code === '23505') { return Either.left(new Error('Participante já está nesta expedição.')) } return Either.left(new CollectionError({ message: 'Falha ao adicionar participante', cause: error })) @@ -251,15 +252,15 @@ export class ExpedicaoCollectionKnexAdapter implements ExpedicaoCollection { } } - async substituteRoute(expedicaoId: number, rotas: number[]): Promise> { + async substituteRoute(expedicaoId: number, rotas: number[]): Promise> { try { - await this.knex.transaction(async (trx) => { + await this.knex.transaction(async trx => { // usa uma transaction para garantir que a exclusão e a inserção ocorram sem a perda de dados em caso de falha. // se qualquer operação falhar, a transação será revertida e nenhuma alteração será feita no banco de dados. // devido a constraint unique, estamos deletando todas as rotas da expedição e inserindo novamente, - // já na ordem correta e seguindo a constraint. + // já na ordem correta e seguindo a constraint. await trx('expedicoes_rotas').where('expedicao_id', expedicaoId).delete() - + if (rotas.length > 0) { const insertData = rotas.map((cidadeId, index) => ({ expedicao_id: expedicaoId, diff --git a/src/library/http/error/ConflictError.ts b/src/library/http/error/ConflictError.ts index e9c3faa5..b8a99193 100644 --- a/src/library/http/error/ConflictError.ts +++ b/src/library/http/error/ConflictError.ts @@ -1,7 +1,7 @@ -import { HttpError } from "./HttpError"; +import { HttpError } from './HttpError' export class ConflictError extends HttpError { constructor(params: { message: string; report?: unknown; cause?: unknown }) { - super({ ...params, statusCode: 409 }); + super({ ...params, statusCode: 409 }) } } diff --git a/test/integration/expedicao/expedicao-collection.participantes.test.ts b/test/integration/expedicao/expedicao-collection.participantes.test.ts index 2a256fff..e1fb8835 100644 --- a/test/integration/expedicao/expedicao-collection.participantes.test.ts +++ b/test/integration/expedicao/expedicao-collection.participantes.test.ts @@ -1,153 +1,155 @@ -import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + afterAll, beforeAll, describe, expect, test +} from 'vitest' -import { ExpedicaoCollectionKnexAdapter } from "@/infrastructure/ExpedicaoCollectionKnexAdapter"; +import { ExpedicaoCollectionKnexAdapter } from '@/infrastructure/ExpedicaoCollectionKnexAdapter' -import { createTestKnex } from "../setup/app-factory"; +import { createTestKnex } from '../setup/app-factory' import { cleanupExpedicaoFixtures, ExpedicaoFixtures, - seedExpedicaoFixtures, -} from "../setup/seeds/expedicao.seed"; + seedExpedicaoFixtures +} from '../setup/seeds/expedicao.seed' -describe("ExpedicaoCollectionKnexAdapter - Sub-recursos (Participantes e Rotas)", () => { - const knex = createTestKnex(); - const collection = new ExpedicaoCollectionKnexAdapter({ knex }); +describe('ExpedicaoCollectionKnexAdapter - Sub-recursos (Participantes e Rotas)', () => { + const knex = createTestKnex() + const collection = new ExpedicaoCollectionKnexAdapter({ knex }) - let fixtures: ExpedicaoFixtures; + let fixtures: ExpedicaoFixtures beforeAll(async () => { - fixtures = await seedExpedicaoFixtures(knex); - }); + fixtures = await seedExpedicaoFixtures(knex) + }) afterAll(async () => { - await cleanupExpedicaoFixtures(knex, fixtures); - await knex.destroy(); - }); + await cleanupExpedicaoFixtures(knex, fixtures) + await knex.destroy() + }) async function criarExpedicaoParaTeste(prefixo: string) { return collection.create({ descricao: `Expedicao [${prefixo}] - Teste Isolado`, - data_inicio: "2026-04-01", - data_fim: "2026-04-10", + data_inicio: '2026-04-01', + data_fim: '2026-04-10', cidade_id: fixtures.cidades[0], created_by: fixtures.usuarios[0], participantes: [fixtures.usuarios[0]], - rotas: [fixtures.cidades[1]], - }); + rotas: [fixtures.cidades[1]] + }) } - describe("Adicionar e Remover Participantes", () => { - test("addParticipant insere um novo usuário na expedição", async () => { - const created = await criarExpedicaoParaTeste("ADD_PART"); - expect(created.right()).toBe(true); - if (!created.right()) return; - const expedicaoId = created.value.id; + describe('Adicionar e Remover Participantes', () => { + test('addParticipant insere um novo usuário na expedição', async () => { + const created = await criarExpedicaoParaTeste('ADD_PART') + expect(created.right()).toBe(true) + if (!created.right()) return + const expedicaoId = created.value.id try { - const novoParticipante = fixtures.usuarios[1]; + const novoParticipante = fixtures.usuarios[1] const result = await collection.addParticipant( expedicaoId, - novoParticipante, - ); - expect(result.right()).toBe(true); - const banco = await knex("expedicoes_participantes") + novoParticipante + ) + expect(result.right()).toBe(true) + const banco = await knex<{ expedicao_id: number; usuario_id: number | string }>('expedicoes_participantes') .where({ expedicao_id: expedicaoId, usuario_id: novoParticipante }) - .first(); + .first() - expect(banco).toBeDefined(); - expect(Number(banco.usuario_id)).toBe(novoParticipante); + expect(banco).toBeDefined() + expect(Number(banco?.usuario_id)).toBe(novoParticipante) } finally { - await knex("expedicoes").where({ id: expedicaoId }).delete(); + await knex('expedicoes').where({ id: expedicaoId }).delete() } - }); + }) - test("addParticipant falha ao tentar inserir usuário já existente (unique constraint)", async () => { - const created = await criarExpedicaoParaTeste("ADD_FAIL_UNIQUE"); - if (!created.right()) return; - const expedicaoId = created.value.id; + test('addParticipant falha ao tentar inserir usuário já existente (unique constraint)', async () => { + const created = await criarExpedicaoParaTeste('ADD_FAIL_UNIQUE') + if (!created.right()) return + const expedicaoId = created.value.id try { - const participanteExistente = fixtures.usuarios[0]; + const participanteExistente = fixtures.usuarios[0] const result = await collection.addParticipant( expedicaoId, - participanteExistente, - ); + participanteExistente + ) - expect(result.left()).toBe(true); + expect(result.left()).toBe(true) if (result.left()) { - expect(result.value.message).toContain("já está nesta expedição"); + expect(result.value.message).toContain('já está nesta expedição') } } finally { - await knex("expedicoes").where({ id: expedicaoId }).delete(); + await knex('expedicoes').where({ id: expedicaoId }).delete() } - }); + }) - test("removeParticipant exclui o usuário da expedição", async () => { - const created = await criarExpedicaoParaTeste("REM_PART"); - if (!created.right()) return; - const expedicaoId = created.value.id; + test('removeParticipant exclui o usuário da expedição', async () => { + const created = await criarExpedicaoParaTeste('REM_PART') + if (!created.right()) return + const expedicaoId = created.value.id try { - const participanteParaRemover = fixtures.usuarios[0]; + const participanteParaRemover = fixtures.usuarios[0] const result = await collection.removeParticipant( expedicaoId, - participanteParaRemover, - ); - expect(result.right()).toBe(true); + participanteParaRemover + ) + expect(result.right()).toBe(true) - const banco = await knex("expedicoes_participantes") + const banco = await knex<{ expedicao_id: number; usuario_id: number | string }>('expedicoes_participantes') .where({ expedicao_id: expedicaoId, - usuario_id: participanteParaRemover, + usuario_id: participanteParaRemover }) - .first(); + .first() - expect(banco).toBeUndefined(); + expect(banco).toBeUndefined() } finally { - await knex("expedicoes").where({ id: expedicaoId }).delete(); + await knex('expedicoes').where({ id: expedicaoId }).delete() } - }); - }); + }) + }) - describe("Substituir Rotas", () => { - test("substituteRoute remove as antigas e insere as novas na ordem correta", async () => { - const created = await criarExpedicaoParaTeste("SUB_ROTAS"); - if (!created.right()) return; - const expedicaoId = created.value.id; + describe('Substituir Rotas', () => { + test('substituteRoute remove as antigas e insere as novas na ordem correta', async () => { + const created = await criarExpedicaoParaTeste('SUB_ROTAS') + if (!created.right()) return + const expedicaoId = created.value.id try { const novasRotas = [ fixtures.cidades[2], fixtures.cidades[0], - fixtures.cidades[2], - ]; + fixtures.cidades[2] + ] const result = await collection.substituteRoute( expedicaoId, - novasRotas, - ); - expect(result.right()).toBe(true); + novasRotas + ) + expect(result.right()).toBe(true) - const rotasNoBanco = await knex("expedicoes_rotas") + const rotasNoBanco = await knex<{ expedicao_id: number; ordem: number; cidade_id: number | string }>('expedicoes_rotas') .where({ expedicao_id: expedicaoId }) - .orderBy("ordem"); + .orderBy('ordem') - expect(rotasNoBanco.length).toBe(3); + expect(rotasNoBanco.length).toBe(3) - const arrayValidacao = rotasNoBanco.map((rota) => [ + const arrayValidacao = rotasNoBanco.map((rota: { ordem: number; cidade_id: number | string }) => [ rota.ordem, - Number(rota.cidade_id), - ]); + Number(rota.cidade_id) + ]) expect(arrayValidacao).toEqual([ [0, novasRotas[0]], [1, novasRotas[1]], - [2, novasRotas[2]], - ]); + [2, novasRotas[2]] + ]) } finally { - await knex("expedicoes").where({ id: expedicaoId }).delete(); + await knex('expedicoes').where({ id: expedicaoId }).delete() } - }); - }); -}); + }) + }) +}) From ed1d5194adb01ca380b0732c5a5171636170c39d Mon Sep 17 00:00:00 2001 From: Dimitri Date: Thu, 24 Sep 2026 13:52:57 -0300 Subject: [PATCH 12/16] fix: lint reafctor --- src/application/expedicao/index.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/application/expedicao/index.ts b/src/application/expedicao/index.ts index f81a13e2..051fdf75 100644 --- a/src/application/expedicao/index.ts +++ b/src/application/expedicao/index.ts @@ -1,26 +1,23 @@ import { type Knex } from 'knex' +import { AdicionaParticipanteUseCase } from '@/domain/expedicao/AdcionaParticipanteUseCase' import { AtualizaExpedicaoUseCase } from '@/domain/expedicao/AtualizaExpedicaoUseCase' import { BuscaExpedicaoUseCase } from '@/domain/expedicao/BuscaExpedicaoUseCase' import { CadastraExpedicaoUseCase } from '@/domain/expedicao/CadastraExpedicaoUseCase' import { DeletaExpedicaoUseCase } from '@/domain/expedicao/DeletaExpedicaoUseCase' import { ListaExpedicoesUseCase } from '@/domain/expedicao/ListaExpedicoesUseCase' -import { createExpedicaoCollection } from '@/factory/ExpedicaoCollectionFactory' - -import { AdicionaParticipanteUseCase } from '@/domain/expedicao/AdcionaParticipanteUseCase' import { RemoveParticipanteUseCase } from '@/domain/expedicao/RemoveParticipanteUseCase' import { SubstituiRotasUseCase } from '@/domain/expedicao/SubstituiRotaUseCase' - +import { createExpedicaoCollection } from '@/factory/ExpedicaoCollectionFactory' import { Method } from '@/library/http/common' import { Route } from '@/library/http/Router' +import { AdicionaParticipanteController } from './AdicionaParticipanteController' import { AtualizaExpedicaoController } from './AtualizaExpedicaoController' import { BuscaExpedicaoController } from './BuscaExpedicaoController' import { CadastraExpedicaoController } from './CadastraExpedicaoController' import { DeletaExpedicaoController } from './DeletaExpedicaoController' import { ListaExpedicoesController } from './ListaExpedicoesController' - -import { AdicionaParticipanteController } from './AdicionaParticipanteController' import { RemoveParticipanteController } from './RemoveParticipanteController' import { SubstituiRotasController } from './SubstituirRotaController' From 54724fb58c50144d10a11f0b4203eaca93b103ca Mon Sep 17 00:00:00 2001 From: Dimitri Date: Fri, 25 Sep 2026 12:22:04 -0300 Subject: [PATCH 13/16] fix: corrige inconsistencia com "SubstitiuiRotasController" --- .../{SubstituirRotaController.ts => SubstituiRotaController.ts} | 0 src/application/expedicao/index.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/application/expedicao/{SubstituirRotaController.ts => SubstituiRotaController.ts} (100%) diff --git a/src/application/expedicao/SubstituirRotaController.ts b/src/application/expedicao/SubstituiRotaController.ts similarity index 100% rename from src/application/expedicao/SubstituirRotaController.ts rename to src/application/expedicao/SubstituiRotaController.ts diff --git a/src/application/expedicao/index.ts b/src/application/expedicao/index.ts index 051fdf75..f38d475c 100644 --- a/src/application/expedicao/index.ts +++ b/src/application/expedicao/index.ts @@ -19,7 +19,7 @@ import { CadastraExpedicaoController } from './CadastraExpedicaoController' import { DeletaExpedicaoController } from './DeletaExpedicaoController' import { ListaExpedicoesController } from './ListaExpedicoesController' import { RemoveParticipanteController } from './RemoveParticipanteController' -import { SubstituiRotasController } from './SubstituirRotaController' +import { SubstituiRotasController } from './SubstituiRotaController' export function routes(knex: Knex): Route[] { const expedicaoCollection = createExpedicaoCollection() From 2f9f3d66f4817040edc4f5cd86c9c6a60d03e9a6 Mon Sep 17 00:00:00 2001 From: Dimitri Date: Fri, 25 Sep 2026 19:06:29 -0300 Subject: [PATCH 14/16] fix: remove interface duplicada ExpedicaoListItem --- src/domain/expedicao/Expedicao.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/domain/expedicao/Expedicao.ts b/src/domain/expedicao/Expedicao.ts index 0a22d4f5..f3cc1f7e 100644 --- a/src/domain/expedicao/Expedicao.ts +++ b/src/domain/expedicao/Expedicao.ts @@ -12,11 +12,6 @@ export interface Attributes { updated_by: number | null } -export interface ExpedicaoListItem extends Attributes { - participantes: number[] - rotas: number[] -} - export interface ParticipanteDetalhado { id: number nome: string From 3efcf58adc68708548323eebef5bbe78e692bd50 Mon Sep 17 00:00:00 2001 From: Dimitri Date: Fri, 25 Sep 2026 19:07:50 -0300 Subject: [PATCH 15/16] refactor: implementa DuplicateParticipantError para tratamento do 409 --- src/infrastructure/ExpedicaoCollectionKnexAdapter.ts | 6 +++++- src/infrastructure/error/DuplicateParticipantError.ts | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 src/infrastructure/error/DuplicateParticipantError.ts diff --git a/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts b/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts index 6b5538eb..1efdf66d 100644 --- a/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts +++ b/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts @@ -6,6 +6,7 @@ import { import { ExpedicaoCollection, ExpedicaoFilters, ExpedicaoListItem, ParticipanteExpedicao, Paginated } from '@/domain/expedicao/ExpedicaoCollection' +import { DuplicateParticipantError } from '@/infrastructure/error/DuplicateParticipantError' import { Either } from '@/library/either/Either' import { CollectionError } from './error/CollectionError' @@ -281,7 +282,10 @@ export class ExpedicaoCollectionKnexAdapter implements ExpedicaoCollection { const dbError = error as { code?: string } if (dbError.code === '23505') { - return Either.left(new Error('Participante já está nesta expedição.')) + return Either.left(new DuplicateParticipantError({ + message: 'O usuário já está nesta expedição', + cause: dbError + })) } return Either.left(new CollectionError({ message: 'Falha ao adicionar participante', cause: error })) } diff --git a/src/infrastructure/error/DuplicateParticipantError.ts b/src/infrastructure/error/DuplicateParticipantError.ts new file mode 100644 index 00000000..3bad74da --- /dev/null +++ b/src/infrastructure/error/DuplicateParticipantError.ts @@ -0,0 +1,7 @@ +import { InfrastructureError } from './InfrastructureError' + +export class DuplicateParticipantError extends InfrastructureError { + constructor(params: { message: string; cause?: unknown }) { + super(params) + } +} From 66466f38a29b4f7fd0192d6e4dd9071264988e41 Mon Sep 17 00:00:00 2001 From: Dimitri Date: Fri, 25 Sep 2026 19:10:38 -0300 Subject: [PATCH 16/16] refactor: altera mapeamento de erro para usar error.name e corrige o nome do SubstituiRotaController --- .../expedicao/AdicionaParticipanteController.ts | 12 ++++++------ src/application/expedicao/SubstituiRotaController.ts | 5 +++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/application/expedicao/AdicionaParticipanteController.ts b/src/application/expedicao/AdicionaParticipanteController.ts index b6198278..630225e2 100644 --- a/src/application/expedicao/AdicionaParticipanteController.ts +++ b/src/application/expedicao/AdicionaParticipanteController.ts @@ -43,16 +43,16 @@ export class AdicionaParticipanteController implements RequestHandler { } const result = await this.adicionaParticipanteUseCase.execute(Number(expedicaoId), usuarioId) + if (result.left()) { - if (result.value.message.includes('já está nesta expedição')) { - return new ConflictError({ message: result.value.message }) + const error = result.value + if (error.name === 'DuplicateParticipantError') { + return new ConflictError({ message: error.message }) } - - if (result.value.name === 'CollectionError' || result.value.message.includes('Falha ao adicionar')) { + if (error.name === 'CollectionError') { return new InternalServerError({ message: 'Falha interna ao adicionar participante' }) } - - return new BadRequestError({ message: result.value.message }) + return new BadRequestError({ message: error.message }) } return { diff --git a/src/application/expedicao/SubstituiRotaController.ts b/src/application/expedicao/SubstituiRotaController.ts index 3883b85c..f4525c7c 100644 --- a/src/application/expedicao/SubstituiRotaController.ts +++ b/src/application/expedicao/SubstituiRotaController.ts @@ -41,10 +41,11 @@ export class SubstituiRotasController implements RequestHandler { const result = await this.SubstituiRotasUseCase.execute(Number(expedicaoId), rotas) if (result.left()) { - if (result.value.name === 'CollectionError' || result.value.message.includes('Falha')) { + const error = result.value + if (error.name === 'CollectionError') { return new InternalServerError({ message: 'Falha interna ao substituir rotas' }) } - return new BadRequestError({ message: result.value.message }) + return new BadRequestError({ message: error.message }) } return {