diff --git a/src/application/create-app.ts b/src/application/create-app.ts index a3b3901c..f3ad8060 100644 --- a/src/application/create-app.ts +++ b/src/application/create-app.ts @@ -13,6 +13,7 @@ import { assets, upload } from '../config/directory' import legacyErrors from '../middlewares/erros-middleware' import { generatePreview, reportPreview } from '../reports/controller' import { routes as createEstadoRoutes } from './estado' +import { routes as createExpedicoesRoutes } from './expedicao' import { routes as createFaseSucessionalRoutes } from './fase-sucessional' import { routes as createPaisRoutes } from './pais' import { routes as createVegetacaoRoutes } from './vegetacao' @@ -59,7 +60,8 @@ export function createApp({ ...createPaisRoutes(knex), ...createEstadoRoutes(knex), ...createFaseSucessionalRoutes(knex), - ...createVegetacaoRoutes(knex) + ...createVegetacaoRoutes(knex), + ...createExpedicoesRoutes(knex) ] const application = new ExpressApplication({ logger }) diff --git a/src/application/expedicao/AtualizaExpedicaoController.ts b/src/application/expedicao/AtualizaExpedicaoController.ts new file mode 100644 index 00000000..11a6962b --- /dev/null +++ b/src/application/expedicao/AtualizaExpedicaoController.ts @@ -0,0 +1,109 @@ +import { AtualizaExpedicaoUseCase } from '@/domain/expedicao/AtualizaExpedicaoUseCase' +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 { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + atualizaExpedicaoUseCase: AtualizaExpedicaoUseCase +} + +interface CustomHttpRequest extends HttpRequest { + usuario?: { + id: number + tipo_usuario_id: number + } +} + +export class AtualizaExpedicaoController implements RequestHandler { + private readonly atualizaExpedicaoUseCase: AtualizaExpedicaoUseCase + + constructor(dependencies: Dependencies) { + this.atualizaExpedicaoUseCase = dependencies.atualizaExpedicaoUseCase + } + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + const { expedicaoId } = request.params + const id = Number(expedicaoId) + + if (!expedicaoId || Number.isNaN(id)) { + return new BadRequestError({ message: 'O ID da expedição é inválido.' }) + } + + const { + descricao, data_inicio, data_fim, cidade_id + } = request.body as { + descricao: string | null + data_inicio: string + data_fim: string + cidade_id: number + } + + const isValidDate = (dateString: string) => { + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateString)) return false + + const [ + year, + month, + day + ] = dateString.split('-').map(Number) + const date = new Date(year, month - 1, day) + + return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day + } + + if (typeof data_inicio !== 'string' || !isValidDate(data_inicio)) { + return new BadRequestError({ message: 'A data_inicio é obrigatória e deve ser uma data válida no calendário (formato YYYY-MM-DD).' }) + } + if (typeof data_fim !== 'string' || !isValidDate(data_fim)) { + return new BadRequestError({ message: 'A data_fim é obrigatória e deve ser uma data válida no calendário (formato YYYY-MM-DD).' }) + } + if (!Number.isInteger(cidade_id) || cidade_id <= 0) { + return new BadRequestError({ message: 'O cidade_id é obrigatório e deve ser um número inteiro válido.' }) + } + if (descricao !== undefined && descricao !== null && typeof descricao !== 'string') { + return new BadRequestError({ message: 'A descricao, se informada, deve ser um texto.' }) + } + + const updated_by = 10 // MOCK TEMPORÁRIO, trocar por request.usuario.id + + const result = await this.atualizaExpedicaoUseCase.execute(id, { + descricao: descricao ?? null, + data_inicio, + data_fim, + cidade_id, + updated_by + }) + + if (result.left()) { + const error = result.value + + // Não encontrado (404) + if (error.message === 'Expedição não encontrada') { + return new NotFoundError({ message: error.message }) + } + + // Erro de Infraestrutura / Banco de Dados (500) + if (error.name === 'CollectionError' || error.message.includes('Failed to update')) { + return new InternalServerError({ message: 'Falha interna ao atualizar expedição' }) + } + + // Erro de validação da Entidade / Regra de Negócio (400) + return new BadRequestError({ message: error.message }) + } + + return { + statusCode: StatusCode.Ok, + body: result.value + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao atualizar expedição' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/BuscaExpedicaoController.ts b/src/application/expedicao/BuscaExpedicaoController.ts new file mode 100644 index 00000000..3d62dd55 --- /dev/null +++ b/src/application/expedicao/BuscaExpedicaoController.ts @@ -0,0 +1,59 @@ +import { BuscaExpedicaoUseCase } from '@/domain/expedicao/BuscaExpedicaoUseCase' +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 { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + buscaExpedicaoUseCase: BuscaExpedicaoUseCase +} + +interface CustomHttpRequest extends HttpRequest { + params: Record +} + +export class BuscaExpedicaoController implements RequestHandler { + private readonly buscaExpedicaoUseCase: BuscaExpedicaoUseCase + + constructor(dependencies: Dependencies) { + this.buscaExpedicaoUseCase = dependencies.buscaExpedicaoUseCase + } + + // Busca expedição por ID + // GET /v2/expedicoes/:expedicaoId + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + const { expedicaoId } = request.params + + // validação id + if (expedicaoId === undefined || expedicaoId === null || expedicaoId === '' || !/^\d+$/.test(expedicaoId)) { + return new BadRequestError({ message: 'expedicaoId inválido' }) + } + + const result = await this.buscaExpedicaoUseCase.execute({ id: Number(expedicaoId) }) + + // Falha na infra ou exceção de regra de negócio + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + // sucesso na querry, mas a expedição não encontrada + if (!result.value) { + return new NotFoundError({ message: 'Expedição não encontrada' }) + } + + // sucesso + return { + statusCode: StatusCode.Ok, + body: result.value + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao buscar expedição por ID' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/CadastraExpedicaoController.ts b/src/application/expedicao/CadastraExpedicaoController.ts new file mode 100644 index 00000000..a557e64d --- /dev/null +++ b/src/application/expedicao/CadastraExpedicaoController.ts @@ -0,0 +1,101 @@ +import { CadastraExpedicaoUseCase } from '@/domain/expedicao/CadastraExpedicaoUseCase' +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' +// import { TIPOS_USUARIOS } from '@/middlewares/tokens-middleware' +// import { UnauthorizedError } from '@/library/http/error/UnauthorizedError' + +interface Dependencies { + cadastraExpedicaoUseCase: CadastraExpedicaoUseCase +} + +interface CustomHttpRequest extends HttpRequest { + usuario?: { + id: number + tipo_usuario_id: number + } +} + +export class CadastraExpedicaoController implements RequestHandler { + private readonly cadastraExpedicaoUseCase: CadastraExpedicaoUseCase + + constructor(dependencies: Dependencies) { + this.cadastraExpedicaoUseCase = dependencies.cadastraExpedicaoUseCase + } + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + // TODO: AUTENTICAÇÃO TEMPORARIAMENTE DESABILITADA + // A infraestrutura de request HTTP autenticada está sendo desenvolvida. + // Assim que integrada, descomentar a validação abaixo e remover o mock do 'created_by'. + + // if (!request.usuario || ![TIPOS_USUARIOS.CURADOR, TIPOS_USUARIOS.OPERADOR].includes(request.usuario.tipo_usuario_id)) { + // return new UnauthorizedError({ message: 'Não tem permissão para realizar esta ação' }) + // } + + const { + descricao, data_inicio, data_fim, cidade_id, participantes, rotas + } = request.body as { + descricao?: string | null + data_inicio: string + data_fim: string + cidade_id: number + participantes?: number[] + rotas?: number[] + } + + // VALIDAÇÃO MANUAL DE DADOS + if (!data_inicio || Number.isNaN(Date.parse(data_inicio))) { + return new BadRequestError({ message: 'A data_inicio é obrigatória e deve ser uma data válida (ex: YYYY-MM-DD).' }) + } + + if (!data_fim || Number.isNaN(Date.parse(data_fim))) { + return new BadRequestError({ message: 'A data_fim é obrigatória e deve ser uma data válida (ex: YYYY-MM-DD).' }) + } + + if (!cidade_id || typeof cidade_id !== 'number' || cidade_id <= 0) { + return new BadRequestError({ message: 'O cidade_id é obrigatório e deve ser um número válido.' }) + } + + if (descricao !== undefined && descricao !== null && typeof descricao !== 'string') { + return new BadRequestError({ message: 'A descricao, se informada, deve ser um texto.' }) + } + + // MOCK TEMPORÁRIO: Substituir pelo request.usuario.id quando a autenticação estiver pronta + const created_by = 9 // Id válido de usuário para teste + + // EXECUÇÃO DO CASO DE USO + const result = await this.cadastraExpedicaoUseCase.execute({ + descricao: descricao ?? null, + data_inicio, + data_fim, + cidade_id, + created_by, + participantes: participantes ?? [], + rotas: rotas ?? [] + }) + + if (result.left()) { + // Se for um erro que sabemos ser de banco/infraestrutura devolve 500 + if (result.value.name === 'CollectionError' || result.value.message.includes('Failed to create')) { + return new InternalServerError({ message: 'Falha interna ao cadastrar expedição' }) + } + + // Se for um erro de validação de domínio (ex: data_fim antes de data_inicio) devolve 400 + return new BadRequestError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.Created, + body: result.value + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao cadastrar expedição' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/DeletaExpedicaoController.ts b/src/application/expedicao/DeletaExpedicaoController.ts new file mode 100644 index 00000000..be275e4a --- /dev/null +++ b/src/application/expedicao/DeletaExpedicaoController.ts @@ -0,0 +1,52 @@ +import { DeletaExpedicaoUseCase } from '@/domain/expedicao/DeletaExpedicaoUseCase' +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 { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + deletaExpedicaoUseCase: DeletaExpedicaoUseCase +} + +interface CustomHttpRequest extends HttpRequest { + params: Record +} + +export class DeletaExpedicaoController implements RequestHandler { + private readonly deletaExpedicaoUseCase: DeletaExpedicaoUseCase + + constructor(dependencies: Dependencies) { + this.deletaExpedicaoUseCase = dependencies.deletaExpedicaoUseCase + } + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + const { expedicaoId } = request.params + + if (expedicaoId === undefined || expedicaoId === null || expedicaoId === '' || !/^\d+$/.test(expedicaoId)) { + return new BadRequestError({ message: 'O ID da expedição é inválido.' }) + } + + const result = await this.deletaExpedicaoUseCase.execute({ id: Number(expedicaoId) }) + + if (result.left()) { + // Distingue não encontrado (404) de erro no banco (500) + if (result.value.message === 'Expedição não encontrada') { + return new NotFoundError({ message: result.value.message }) + } + return new InternalServerError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.NoContent + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao deletar a expedição.' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/ListaExpedicoesController.ts b/src/application/expedicao/ListaExpedicoesController.ts new file mode 100644 index 00000000..ef49845b --- /dev/null +++ b/src/application/expedicao/ListaExpedicoesController.ts @@ -0,0 +1,109 @@ +import { ExpedicaoFilters } from '@/domain/expedicao/ExpedicaoCollection' +import { ListaExpedicoesUseCase } from '@/domain/expedicao/ListaExpedicoesUseCase' +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 { + listaExpedicoesUseCase: ListaExpedicoesUseCase +} + +// interface do formato da nossa query +interface CustomHttpRequest extends HttpRequest { + params: Record +} + +export class ListaExpedicoesController implements RequestHandler { + private readonly listaExpedicoesUseCase: ListaExpedicoesUseCase + + constructor(dependencies: Dependencies) { + this.listaExpedicoesUseCase = dependencies.listaExpedicoesUseCase + } + + // Listar todas (com ordenação padrão DESC): + // GET /api/v2/expedicoes + + // Filtrar por uma cidade específica: + // GET /api/v2/expedicoes?cidade_id=1100049 + + // Filtrar por intervalo de datas: + // GET /api/v2/expedicoes?data_inicio_de=2026-01-01&data_fim_ate=2026-12-31 + + // Filtrar com ordenação segura: + // GET /api/v2/expedicoes?order=data_inicio:desc + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + const { + cidade_id, + usuario_id, + data_inicio_de, + data_fim_ate, + order, + limite, + pagina + } = request.params + + const filters: ExpedicaoFilters = {} + + if (cidade_id && !Number.isNaN(Number(cidade_id))) { + filters.cidade_id = Number(cidade_id) + } + + if (usuario_id && !Number.isNaN(Number(usuario_id))) { + filters.usuario_id = Number(usuario_id) + } + + if (data_inicio_de) filters.data_inicio_de = data_inicio_de + if (data_fim_ate) filters.data_fim_ate = data_fim_ate + + // Validação da ordenação + if (order) { + const [column, direction] = order.split(':') + const colunasValidas = [ + 'id', + 'data_inicio', + 'data_fim' + ] + const direcoesValidas = ['asc', 'desc'] + + if (!colunasValidas.includes(column) || !direcoesValidas.includes(direction)) { + return new BadRequestError({ + message: 'order inválido. Use o formato "id:asc", "id:desc", "data_inicio:asc" ou "data_fim:desc"' + }) + } + + filters.order = { + column: column as 'id' | 'data_inicio' | 'data_fim', + direction: direction as 'asc' | 'desc' + } + } + + if (limite && !Number.isNaN(Number(limite))) { + filters.limite = Number(limite) + } + if (pagina && !Number.isNaN(Number(pagina))) { + filters.pagina = Number(pagina) + } + + const result = await this.listaExpedicoesUseCase.execute(filters) + + // erro de infra + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.Ok, + body: result.value + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao listar expedições' + 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..a9531788 --- /dev/null +++ b/src/application/expedicao/index.ts @@ -0,0 +1,68 @@ +import { type Knex } from 'knex' + +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 { Method } from '@/library/http/common' +import { Route } from '@/library/http/Router' + +import { AtualizaExpedicaoController } from './AtualizaExpedicaoController' +import { BuscaExpedicaoController } from './BuscaExpedicaoController' +import { CadastraExpedicaoController } from './CadastraExpedicaoController' +import { DeletaExpedicaoController } from './DeletaExpedicaoController' +import { ListaExpedicoesController } from './ListaExpedicoesController' + +export function routes(knex: Knex): Route[] { + const expedicaoCollection = createExpedicaoCollection() + + return [ + { + method: Method.Post, + path: '/v2/expedicoes', + handlers: [ + new CadastraExpedicaoController({ + cadastraExpedicaoUseCase: new CadastraExpedicaoUseCase({ expedicaoCollection }) + }) + ] + }, + { + method: Method.Get, + path: '/v2/expedicoes', + handlers: [ + new ListaExpedicoesController({ + listaExpedicoesUseCase: new ListaExpedicoesUseCase({ expedicaoCollection }) + }) + ] + }, + { + method: Method.Get, + path: '/v2/expedicoes/:expedicaoId', + handlers: [ + new BuscaExpedicaoController({ + buscaExpedicaoUseCase: new BuscaExpedicaoUseCase({ expedicaoCollection }) + }) + ] + }, + { + method: Method.Delete, + path: '/v2/expedicoes/:expedicaoId', + handlers: [ + new DeletaExpedicaoController({ + deletaExpedicaoUseCase: new DeletaExpedicaoUseCase({ expedicaoCollection }) + }) + ] + }, + { + method: Method.Put, + path: '/v2/expedicoes/:expedicaoId', + handlers: [ + new AtualizaExpedicaoController({ + atualizaExpedicaoUseCase: new AtualizaExpedicaoUseCase({ expedicaoCollection }) + }) + ] + } + ] +} diff --git a/src/domain/expedicao/AtualizaExpedicaoUseCase.ts b/src/domain/expedicao/AtualizaExpedicaoUseCase.ts new file mode 100644 index 00000000..d8249880 --- /dev/null +++ b/src/domain/expedicao/AtualizaExpedicaoUseCase.ts @@ -0,0 +1,32 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, Expedicao, UpdateAttributes +} from './Expedicao' +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class AtualizaExpedicaoUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + async execute(id: number, changes: UpdateAttributes): Promise> { + const found = await this.expedicaoCollection.findById(id) + if (found.left()) return Either.left(found.value) + if (!found.value) return Either.left(new Error('Expedição não encontrada')) + + const current = Expedicao.create(found.value) + if (current.left()) return Either.left(current.value) + + const updated = current.value.update(changes) + if (updated.left()) return Either.left(updated.value) + + return this.expedicaoCollection.update(id, changes) + } +} diff --git a/src/domain/expedicao/BuscaExpedicaoUseCase.ts b/src/domain/expedicao/BuscaExpedicaoUseCase.ts new file mode 100644 index 00000000..632551f9 --- /dev/null +++ b/src/domain/expedicao/BuscaExpedicaoUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Expedicao' +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class BuscaExpedicaoUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + async execute({ id }: { id: number }): Promise> { + return this.expedicaoCollection.findById(id) + } +} diff --git a/src/domain/expedicao/CadastraExpedicaoUseCase.ts b/src/domain/expedicao/CadastraExpedicaoUseCase.ts new file mode 100644 index 00000000..8869c573 --- /dev/null +++ b/src/domain/expedicao/CadastraExpedicaoUseCase.ts @@ -0,0 +1,37 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, CreateAttributes, Expedicao +} from './Expedicao' +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class CadastraExpedicaoUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + async execute(dados: CreateAttributes): Promise> { + // Valida as regras de negócio passando pela Entidade. + // Entidade exige id e datas de auditoria na interface, mockado para a validação passar. + const validacao = Expedicao.create({ + ...dados, + id: 0, + created_at: new Date(), + updated_at: new Date(), + updated_by: null + } as Attributes) + + if (validacao.left()) { + return Either.left(validacao.value) + } + + // Adapter para persistir no banco + return await this.expedicaoCollection.create(dados) + } +} diff --git a/src/domain/expedicao/DeletaExpedicaoUseCase.ts b/src/domain/expedicao/DeletaExpedicaoUseCase.ts new file mode 100644 index 00000000..300b1ce0 --- /dev/null +++ b/src/domain/expedicao/DeletaExpedicaoUseCase.ts @@ -0,0 +1,20 @@ +import { + Either +} from '@/library/either/Either' + +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class DeletaExpedicaoUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + async execute({ id }: { id: number }): Promise> { + return this.expedicaoCollection.delete(id) + } +} diff --git a/src/domain/expedicao/Expedicao.ts b/src/domain/expedicao/Expedicao.ts index 1c99f99e..b3e53a66 100644 --- a/src/domain/expedicao/Expedicao.ts +++ b/src/domain/expedicao/Expedicao.ts @@ -18,7 +18,13 @@ export type CreateAttributes = participantes: number[] rotas: number[] } - +export interface UpdateAttributes { + descricao: string | null + data_inicio: string + data_fim: string + cidade_id: number + updated_by: number +} export class Expedicao { readonly id: number readonly descricao: string | null @@ -61,4 +67,26 @@ export class Expedicao { return Either.right(new Expedicao(attributes)) } + + toAttributes(): Attributes { + return { + id: this.id, + descricao: this.descricao, + data_inicio: this.data_inicio, + data_fim: this.data_fim, + cidade_id: this.cidade_id, + created_at: this.created_at, + updated_at: this.updated_at, + created_by: this.created_by, + updated_by: this.updated_by + } + } + + update(changes: UpdateAttributes): Either { + return Expedicao.create({ + ...this.toAttributes(), + ...changes, + updated_at: new Date() + }) + } } diff --git a/src/domain/expedicao/ExpedicaoCollection.ts b/src/domain/expedicao/ExpedicaoCollection.ts index fbeaa0a4..6c3ec532 100644 --- a/src/domain/expedicao/ExpedicaoCollection.ts +++ b/src/domain/expedicao/ExpedicaoCollection.ts @@ -1,6 +1,8 @@ import { Either } from '@/library/either/Either' -import { Attributes, CreateAttributes } from './Expedicao' +import { + Attributes, CreateAttributes, UpdateAttributes +} from './Expedicao' export interface ExpedicaoOrder { column: 'id' | 'data_inicio' | 'data_fim' @@ -13,10 +15,25 @@ export interface ExpedicaoFilters { data_inicio_de?: string data_fim_ate?: string order?: ExpedicaoOrder + limite?: number + pagina?: number +} + +export interface Paginated { + itens: T[] + total: number + limite: number + pagina: number +} +export interface ExpedicaoListItem extends Attributes { + participantes: number[] + rotas: number[] } export interface ExpedicaoCollection { - findAll(filters: ExpedicaoFilters): Promise> + findAll(filters: ExpedicaoFilters): Promise>> findById(id: number): Promise> create(attributes: CreateAttributes): Promise> + delete(id: number): Promise> + update(id: number, attributes: UpdateAttributes): Promise> } diff --git a/src/domain/expedicao/ListaExpedicoesUseCase.ts b/src/domain/expedicao/ListaExpedicoesUseCase.ts new file mode 100644 index 00000000..a3974f30 --- /dev/null +++ b/src/domain/expedicao/ListaExpedicoesUseCase.ts @@ -0,0 +1,22 @@ +import { Either } from '@/library/either/Either' + +import { + ExpedicaoCollection, ExpedicaoFilters, ExpedicaoListItem, Paginated +} from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class ListaExpedicoesUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + async execute(filters: ExpedicaoFilters): Promise>> { + // repassa os filtros para o adapter realizar a busca + return await this.expedicaoCollection.findAll(filters) + } +} 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() }) +}) diff --git a/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts b/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts index 85fcf963..e68a2bc0 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, UpdateAttributes +} 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,43 @@ export class ExpedicaoCollectionKnexAdapter implements ExpedicaoCollection { return Either.left(new CollectionError({ message: 'Failed to create expedição', cause: error })) } } + + async delete(id: number): Promise> { + try { + const affectedRows = await this.knex.transaction(async trx => { + // Deleta as tabelas filhas primeiro + await trx('expedicoes_participantes').where('expedicao_id', id).delete() + await trx('expedicoes_rotas').where('expedicao_id', id).delete() + + // Deleta a expedição e converte a tipagem do resultado explicitamente para number + const count = await trx('expedicoes').where('id', id).delete() + return count + }) + + // Se apagou 0 linhas, é porque o ID não existia + if (affectedRows === 0) { + return Either.left(new Error('Expedição não encontrada')) + } + + return Either.right(undefined) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to delete expedição', cause: error })) + } + } + + async update(id: number, attributes: UpdateAttributes): Promise> { + try { + await this.knex('expedicoes') + .where({ id }) + .update({ ...attributes, updated_at: this.knex.fn.now() }) + + const row = await this.select().where('expedicoes.id', id).first() as Row | undefined + + if (!row) return Either.left(new Error('Expedição não encontrada')) + + return Either.right(toAttributes(row)) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to update expedição', cause: error })) + } + } } diff --git a/test/integration/expedicao/expedicao-collection.test.ts b/test/integration/expedicao/expedicao-collection.test.ts index fc21cf95..b0beee26 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() } @@ -134,4 +134,101 @@ describe('ExpedicaoCollectionKnexAdapter', () => { if (!found.right()) return expect(found.value).toBeNull() }) + describe('filtros e paginação no findAll', () => { + let ids: number[] = [] + + beforeAll(async () => { + // Cria 3 expedições distintas para testar ordenação, paginação e filtros + const exp1 = await collection.create({ + ...novaExpedicao(), + cidade_id: fixtures.cidades[0], + data_inicio: '2026-05-01', + data_fim: '2026-05-10' + }) + const exp2 = await collection.create({ + ...novaExpedicao(), + cidade_id: fixtures.cidades[1], + data_inicio: '2026-06-01', + data_fim: '2026-06-10' + }) + const exp3 = await collection.create({ + ...novaExpedicao(), + cidade_id: fixtures.cidades[0], // Mesma cidade da exp1 + data_inicio: '2026-07-01', + data_fim: '2026-07-10' + }) + + if (exp1.right() && exp2.right() && exp3.right()) { + ids = [ + exp1.value.id, + exp2.value.id, + exp3.value.id + ] + } + }) + + afterAll(async () => { + await knex('expedicoes').whereIn('id', ids).delete() + }) + + test('devolve metadados de paginação padrão e limite de itens', async () => { + const found = await collection.findAll({ limite: 2, pagina: 1 }) + expect(found.right()).toBe(true) + if (!found.right()) return + + // Deve ter limite 2, estar na página 1 e totalizar no mínimo 3 + expect(found.value.limite).toBe(2) + expect(found.value.pagina).toBe(1) + expect(found.value.total).toBeGreaterThanOrEqual(3) + expect(found.value.itens.length).toBeLessThanOrEqual(2) + }) + + test('filtra por cidade_id', async () => { + const found = await collection.findAll({ cidade_id: fixtures.cidades[1] }) + expect(found.right()).toBe(true) + if (!found.right()) return + + // Deve achar apenas a exp2 + expect(found.value.itens.map(e => e.id)).toContain(ids[1]) + expect(found.value.itens.map(e => e.id)).not.toContain(ids[0]) + expect(found.value.itens.map(e => e.id)).not.toContain(ids[2]) + }) + + test('filtra por intervalo de datas (data_inicio_de e data_fim_ate)', async () => { + // Busca expedições que comecem a partir de junho e terminem até meio de julho + const found = await collection.findAll({ + data_inicio_de: '2026-06-01', + data_fim_ate: '2026-07-15' + }) + expect(found.right()).toBe(true) + if (!found.right()) return + + // Deve achar exp2 (junho) e exp3 (julho), mas ignorar exp1 (maio) + const returnedIds = found.value.itens.map(e => e.id) + expect(returnedIds).toContain(ids[1]) + expect(returnedIds).toContain(ids[2]) + expect(returnedIds).not.toContain(ids[0]) + }) + + test('aplica ordenação (order) corretamente', async () => { + const foundDesc = await collection.findAll({ order: { column: 'data_inicio', direction: 'desc' } }) + expect(foundDesc.right()).toBe(true) + if (!foundDesc.right()) return + + const foundAsc = await collection.findAll({ order: { column: 'data_inicio', direction: 'asc' } }) + expect(foundAsc.right()).toBe(true) + if (!foundAsc.right()) return + + const idxDesc3 = foundDesc.value.itens.findIndex(e => e.id === ids[2]) // Julho + const idxDesc1 = foundDesc.value.itens.findIndex(e => e.id === ids[0]) // Maio + + const idxAsc3 = foundAsc.value.itens.findIndex(e => e.id === ids[2]) // Julho + const idxAsc1 = foundAsc.value.itens.findIndex(e => e.id === ids[0]) // Maio + + // DESC: Julho (exp3) vem antes de Maio (exp1) + expect(idxDesc3).toBeLessThan(idxDesc1) + // ASC: Maio (exp1) vem antes de Julho (exp3) + expect(idxAsc1).toBeLessThan(idxAsc3) + }) + }) })