diff --git a/src/application/expedicao/AdicionaParticipanteController.ts b/src/application/expedicao/AdicionaParticipanteController.ts new file mode 100644 index 00000000..630225e2 --- /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: unknown + 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()) { + const error = result.value + if (error.name === 'DuplicateParticipantError') { + return new ConflictError({ message: error.message }) + } + if (error.name === 'CollectionError') { + return new InternalServerError({ message: 'Falha interna ao adicionar participante' }) + } + return new BadRequestError({ message: error.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..726da6a4 --- /dev/null +++ b/src/application/expedicao/RemoveParticipanteController.ts @@ -0,0 +1,58 @@ +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/SubstituiRotaController.ts b/src/application/expedicao/SubstituiRotaController.ts new file mode 100644 index 00000000..f4525c7c --- /dev/null +++ b/src/application/expedicao/SubstituiRotaController.ts @@ -0,0 +1,60 @@ +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()) { + const error = result.value + if (error.name === 'CollectionError') { + return new InternalServerError({ message: 'Falha interna ao substituir rotas' }) + } + return new BadRequestError({ message: error.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 index a9531788..f38d475c 100644 --- a/src/application/expedicao/index.ts +++ b/src/application/expedicao/index.ts @@ -1,19 +1,25 @@ 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 { 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 { RemoveParticipanteController } from './RemoveParticipanteController' +import { SubstituiRotasController } from './SubstituiRotaController' export function routes(knex: Knex): Route[] { const expedicaoCollection = createExpedicaoCollection() @@ -63,6 +69,33 @@ export function routes(knex: Knex): Route[] { atualizaExpedicaoUseCase: new AtualizaExpedicaoUseCase({ expedicaoCollection }) }) ] + }, + { + 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 }) + }) + ] } ] } diff --git a/src/domain/expedicao/AdcionaParticipanteUseCase.ts b/src/domain/expedicao/AdcionaParticipanteUseCase.ts new file mode 100644 index 00000000..caeb2e0c --- /dev/null +++ b/src/domain/expedicao/AdcionaParticipanteUseCase.ts @@ -0,0 +1,19 @@ +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/Expedicao.ts b/src/domain/expedicao/Expedicao.ts index b3e53a66..f3cc1f7e 100644 --- a/src/domain/expedicao/Expedicao.ts +++ b/src/domain/expedicao/Expedicao.ts @@ -12,6 +12,24 @@ export interface Attributes { updated_by: number | null } +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 & { diff --git a/src/domain/expedicao/ExpedicaoCollection.ts b/src/domain/expedicao/ExpedicaoCollection.ts index 85141780..17096b1b 100644 --- a/src/domain/expedicao/ExpedicaoCollection.ts +++ b/src/domain/expedicao/ExpedicaoCollection.ts @@ -10,13 +10,13 @@ export interface ExpedicaoOrder { } export interface ExpedicaoFilters { + pagina?: number + limite?: number cidade_id?: number usuario_id?: number data_inicio_de?: string data_fim_ate?: string order?: ExpedicaoOrder - limite?: number - pagina?: number } export interface Paginated { @@ -39,6 +39,12 @@ export interface ExpedicaoCollection { 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> + delete(id: number): Promise> update(id: number, attributes: UpdateAttributes): Promise> } diff --git a/src/domain/expedicao/RemoveParticipanteUseCase.ts b/src/domain/expedicao/RemoveParticipanteUseCase.ts new file mode 100644 index 00000000..c3b3bae5 --- /dev/null +++ b/src/domain/expedicao/RemoveParticipanteUseCase.ts @@ -0,0 +1,19 @@ +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..df4df554 --- /dev/null +++ b/src/domain/expedicao/SubstituiRotaUseCase.ts @@ -0,0 +1,19 @@ +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) + } +} diff --git a/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts b/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts index 58ac26f7..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' @@ -269,4 +270,59 @@ export class ExpedicaoCollectionKnexAdapter implements ExpedicaoCollection { return Either.left(new CollectionError({ message: 'Failed to update 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: unknown) { + const dbError = error as { code?: string } + + if (dbError.code === '23505') { + 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 })) + } + } + + 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 })) + } + } } 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) + } +} diff --git a/src/library/http/error/ConflictError.ts b/src/library/http/error/ConflictError.ts new file mode 100644 index 00000000..b8a99193 --- /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 }) + } +} 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..e1fb8835 --- /dev/null +++ b/test/integration/expedicao/expedicao-collection.participantes.test.ts @@ -0,0 +1,155 @@ +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<{ expedicao_id: number; usuario_id: number | string }>('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<{ expedicao_id: number; usuario_id: number | string }>('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<{ expedicao_id: number; ordem: number; cidade_id: number | string }>('expedicoes_rotas') + .where({ expedicao_id: expedicaoId }) + .orderBy('ordem') + + expect(rotasNoBanco.length).toBe(3) + + const arrayValidacao = rotasNoBanco.map((rota: { ordem: number; cidade_id: number | string }) => [ + 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() + } + }) + }) +})