Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ CORS_METHODS=HEAD,GET,POST,PUT,PATCH,DELETE
CORS_ALLOWED_HEADERS=Content-Type,Authorization

STORAGE_PATH=./uploads
EVIDENCIA_MAX_FILE_SIZE_MB=200

MYSQL_DATABASE=herbario_dev
MYSQL_HOST=127.0.0.1
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ dist/
coverage/
node_modules/

# Yarn Berry local state — este projeto usa Yarn Classic (yarn lockfile v1)
.yarnrc.yml
.yarn/

uploads/
mysql/data/
postgresql/
Expand Down
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@
"liveServer.settings.port": 5501,
"[javascript]": {
"editor.tabSize": 4
}
},
"js/ts.tsdk.path": "./node_modules/typescript/lib"
}
2 changes: 2 additions & 0 deletions compose.integration.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
name: hcf-api-test

services:
postgres:
image: postgis/postgis:18-3.6
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@types/cors": "2.8.19",
"@types/express": "5.0.5",
"@types/morgan": "1.9.10",
"@types/multer": "^2.2.0",
"@types/pg": "^8.16.0",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
Expand All @@ -84,8 +85,8 @@
"globals": "16.5.0",
"husky": "9.1.7",
"npm-run-all": "4.1.5",
"tsx": "4.21.0",
"supertest": "7.2.2",
"tsx": "4.21.0",
"typescript": "5.9.3",
"typescript-eslint": "8.46.3",
"vitest": "4.0.7"
Expand Down
4 changes: 3 additions & 1 deletion src/application/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import legacyErrors from '../middlewares/erros-middleware'
import { generatePreview, reportPreview } from '../reports/controller'
import { routes as createEstadoRoutes } from './estado'
import { routes as createEventoRoutes } from './evento'
import { routes as createEvidenciaRoutes } from './evidencia'
import { routes as createExpedicoesRoutes } from './expedicao'
import { routes as createFaseSucessionalRoutes } from './fase-sucessional'
import { routes as createPaisRoutes } from './pais'
Expand Down Expand Up @@ -64,6 +65,7 @@ export function createApp({
...createFaseSucessionalRoutes(knex),
...createVegetacaoRoutes(knex),
...createEventoRoutes(knex),
...createEvidenciaRoutes(knex),
...createExpedicoesRoutes(knex)
]
const application = new ExpressApplication({ logger })
Expand Down Expand Up @@ -118,7 +120,7 @@ export function createApp({

for (const route of routes) {
const sanitizedPath = `/api/${route.path}`.replaceAll(/\/{2,}/g, '/').replaceAll(/\/$/g, '')
application.endpoint(route.method, sanitizedPath, ...route.handlers)
application.endpoint(route.method, sanitizedPath, route.handlers, route.middlewares)
}

if (legacyRouter) {
Expand Down
4 changes: 3 additions & 1 deletion src/application/evento/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CriarEventoUseCase } from '@/domain/evento/CriarEventoUseCase'
import { ListaEventosUseCase } from '@/domain/evento/ListaEventosUseCase'
import { RemoverEventoUseCase } from '@/domain/evento/RemoverEventoUseCase'
import { EventoCollectionKnexAdapter } from '@/infrastructure/EventoCollectionKnexAdapter'
import { EvidenciaCollectionKnexAdapter } from '@/infrastructure/EvidenciaCollectionKnexAdapter'
import { Method } from '@/library/http/common'
import { Route } from '@/library/http/Router'

Expand All @@ -17,6 +18,7 @@ import { RemoverEventoController } from './RemoverEventoController'

export function routes(knex: Knex): Route[] {
const eventoCollection = new EventoCollectionKnexAdapter({ knex })
const evidenciaCollection = new EvidenciaCollectionKnexAdapter({ knex })

// TODO: adicionar autenticação quando a camada HTTP de autenticação estiver disponível.

Expand Down Expand Up @@ -60,7 +62,7 @@ export function routes(knex: Knex): Route[] {
{
handlers: [
new RemoverEventoController({
removerEventoUseCase: new RemoverEventoUseCase({ eventoCollection })
removerEventoUseCase: new RemoverEventoUseCase({ eventoCollection, evidenciaCollection })
})
],
method: Method.Delete,
Expand Down
112 changes: 112 additions & 0 deletions src/application/evidencia/CriarEvidenciaController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { unlink } from 'node:fs/promises'

import { CriarEvidenciaUseCase } from '@/domain/evidencia/CriarEvidenciaUseCase'
import { ForeignKeyViolationError } from '@/infrastructure/error/ForeignKeyViolationError'
import { InfrastructureError } from '@/infrastructure/error/InfrastructureError'
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 {
criarEvidenciaUseCase: CriarEvidenciaUseCase
}

interface Body {
nome?: unknown
capturado_em?: unknown
}

interface UploadedFile {
filename: string
mimetype: string
size: number
path: string
}

export class CriarEvidenciaController implements RequestHandler {
private readonly criarEvidenciaUseCase: CriarEvidenciaUseCase

constructor(dependencies: Dependencies) {
this.criarEvidenciaUseCase = dependencies.criarEvidenciaUseCase
}

async handle(request: HttpRequest, _next: NextHandler): Promise<HttpResponse | HttpError> {
const { eventoId: rawEventoId, file: rawFile } = request.params as { eventoId?: string; file?: unknown }
const eventoId = parseId(rawEventoId, 'eventoId')
if (eventoId instanceof Error) return new BadRequestError({ message: eventoId.message })

const file = rawFile as UploadedFile | undefined
if (!file) return new BadRequestError({ message: 'Arquivo (campo "arquivo") é obrigatório' })

const body = (request.body ?? {}) as Body

const nome = parseString(body.nome, 'nome')
if (nome instanceof Error) return await cleanupAndReturn(file.path, nome)

const capturadoEm = parseDate(body.capturado_em, 'capturado_em')
if (capturadoEm instanceof Error) return await cleanupAndReturn(file.path, capturadoEm)

// Substituir por request.usuario.id assim que a
// autenticação for integrada.
const usuarioId = null

const result = await this.criarEvidenciaUseCase.execute({
evento_id: eventoId,
nome,
arquivo: file.filename,
mime_type: file.mimetype,
tamanho: file.size,
capturado_em: capturadoEm,
created_by: usuarioId
})

if (result.left()) {
await unlink(file.path).catch(() => {})

const error = result.value
if (error instanceof ForeignKeyViolationError) return new NotFoundError({ message: error.message })
if (!(error instanceof InfrastructureError)) return new BadRequestError({ message: error.message })
return new InternalServerError({ message: error.message })
}

return {
body: { ...result.value, url: `/uploads/evidencias/${result.value.arquivo}` },
statusCode: StatusCode.Created
}
}
}

async function cleanupAndReturn(filePath: string, error: Error): Promise<HttpError> {
await unlink(filePath).catch(() => {})
return new BadRequestError({ message: error.message })
}

function parseId(raw: unknown, field: string): number | Error {
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
return new Error(`${field} inválido`)
}
return Number(raw)
}

function parseString(raw: unknown, field: string): string | Error {
if (typeof raw !== 'string' || !raw.trim()) {
return new Error(`${field} é obrigatório`)
}
return raw
}

function parseDate(raw: unknown, field: string): Date | Error {
if (typeof raw !== 'string') {
return new Error(`${field} inválido. Use uma data no formato ISO 8601`)
}
const date = new Date(raw)
if (Number.isNaN(date.getTime())) {
return new Error(`${field} inválido. Use uma data no formato ISO 8601`)
}
return date
}
43 changes: 43 additions & 0 deletions src/application/evidencia/ListarEvidenciasController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { EvidenciaCollection } from '@/domain/evidencia/EvidenciaCollection'
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 {
evidenciaCollection: EvidenciaCollection
}

export class ListarEvidenciasController implements RequestHandler {
private readonly evidenciaCollection: EvidenciaCollection

constructor(dependencies: Dependencies) {
this.evidenciaCollection = dependencies.evidenciaCollection
}

async handle(request: HttpRequest, _next: NextHandler): Promise<HttpResponse | HttpError> {
const { eventoId: rawEventoId } = request.params as { eventoId?: string }
const eventoId = parseId(rawEventoId, 'eventoId')
if (eventoId instanceof Error) return new BadRequestError({ message: eventoId.message })

const result = await this.evidenciaCollection.findAll({ evento_id: eventoId })
if (result.left()) return new InternalServerError({ message: result.value.message })

const body = result.value.map(evidencia => ({
...evidencia,
url: `/uploads/evidencias/${evidencia.arquivo}`
}))

return { body, statusCode: StatusCode.Ok }
}
}

function parseId(raw: unknown, field: string): number | Error {
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
return new Error(`${field} inválido`)
}
return Number(raw)
}
46 changes: 46 additions & 0 deletions src/application/evidencia/RemoverEvidenciaController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { RemoverEvidenciaUseCase } from '@/domain/evidencia/RemoverEvidenciaUseCase'
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 {
removerEvidenciaUseCase: RemoverEvidenciaUseCase
}

export class RemoverEvidenciaController implements RequestHandler {
private readonly removerEvidenciaUseCase: RemoverEvidenciaUseCase

constructor(dependencies: Dependencies) {
this.removerEvidenciaUseCase = dependencies.removerEvidenciaUseCase
}

async handle(request: HttpRequest, _next: NextHandler): Promise<HttpResponse | HttpError> {
const { evidenciaId: rawEvidenciaId } = request.params as { evidenciaId?: string }
const evidenciaId = parseId(rawEvidenciaId, 'evidenciaId')
if (evidenciaId instanceof Error) return new BadRequestError({ message: evidenciaId.message })

const result = await this.removerEvidenciaUseCase.execute({ id: evidenciaId })

if (result.left()) {
return new InternalServerError({ message: result.value.message })
}

if (!result.value) {
return new NotFoundError({ message: 'Evidência não encontrada' })
}

return { statusCode: StatusCode.NoContent }
}
}

function parseId(raw: unknown, field: string): number | Error {
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
return new Error(`${field} inválido`)
}
return Number(raw)
}
52 changes: 52 additions & 0 deletions src/application/evidencia/ValidarEventoExisteMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import express from 'express'

import { EventoCollection } from '@/domain/evento/EventoCollection'

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace -- é o jeito canônico de estender o Request do Express
namespace Express {
interface Request {
evidenciaEvento?: { expedicaoId: number }
}
}
}

export function validarEventoExisteMiddleware(eventoCollection: EventoCollection): express.RequestHandler {
return (request, response, next) => {
const eventoId = Number(request.params.eventoId)

if (!Number.isInteger(eventoId) || eventoId <= 0) {
response.status(400).json({
error: {
statusCode: 400, name: 'BadRequestError', message: 'eventoId inválido'
}
})
return
}

eventoCollection.findById(eventoId)
.then(result => {
if (result.left()) {
response.status(500).json({
error: {
statusCode: 500, name: 'InternalServerError', message: result.value.message
}
})
return
}

if (!result.value) {
response.status(404).json({
error: {
statusCode: 404, name: 'NotFoundError', message: 'Evento não encontrado'
}
})
return
}

request.evidenciaEvento = { expedicaoId: result.value.expedicao_id }
next()
})
.catch(next)
}
}
Loading
Loading