Skip to content
Open
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"@stylistic/eslint-plugin": "5.5.0",
"@types/cors": "2.8.19",
"@types/express": "5.0.5",
"@types/jsonwebtoken": "^9.0.10",
"@types/morgan": "1.9.10",
"@types/pg": "^8.16.0",
"@types/react": "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
41 changes: 41 additions & 0 deletions src/application/vegetacao/BuscaVegetacaoController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { BuscaVegetacaoPorIdUseCase } from '@/domain/vegetacao/BuscaVegetacaoPorIdUseCase'
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 {
buscaVegetacaoPorIdUseCase: BuscaVegetacaoPorIdUseCase
}

export class BuscaVegetacaoController implements RequestHandler {
private readonly buscaVegetacaoPorIdUseCase: BuscaVegetacaoPorIdUseCase

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

async handle(request: HttpRequest, _next: NextHandler): Promise<HttpResponse | HttpError> {
const { vegetacaoId } = request.params as { vegetacaoId?: string }

if (vegetacaoId === undefined || vegetacaoId === null || vegetacaoId === '' || !/^\d+$/.test(vegetacaoId)) {
return new BadRequestError({ message: 'vegetacaoId inválido' })
}

const result = await this.buscaVegetacaoPorIdUseCase.execute({ id: Number(vegetacaoId) })

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

if (!result.value) {
return new NotFoundError({ message: 'Vegetação não encontrada' })
}

return { statusCode: StatusCode.Ok, body: result.value }
}
}
47 changes: 47 additions & 0 deletions src/application/vegetacao/CadastraVegetacaoController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { CadastraVegetacaoUseCase } from '@/domain/vegetacao/CadastraVegetacaoUseCase'
import { Vegetacao } from '@/domain/vegetacao/Vegetacao'
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 {
cadastraVegetacaoUseCase: CadastraVegetacaoUseCase
}

export class CadastraVegetacaoController implements RequestHandler {
private readonly cadastraVegetacaoUseCase: CadastraVegetacaoUseCase

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

async handle(request: HttpRequest, _next: NextHandler): Promise<HttpResponse | HttpError> {
const { nome } = request.body as { nome?: string }

if (typeof nome !== 'string' || !nome.trim()) {
return new BadRequestError({ message: 'Nome da vegetação não pode ser vazio' })
}

const normalized = nome.trim()
const created = Vegetacao.create({ id: 0, nome: normalized })
if (created.left()) {
return new BadRequestError({ message: created.value.message })
}

const result = await this.cadastraVegetacaoUseCase.execute({ nome: normalized })

if (result.left()) {
if (result.value.message === 'Já existe uma vegetação com esse nome') {
return new ConflictError({ message: 'Já existe uma vegetação com esse nome' })
}
return new InternalServerError({ message: result.value.message })
}

return { statusCode: StatusCode.Created, body: result.value }
}
}
51 changes: 51 additions & 0 deletions src/application/vegetacao/RemoveVegetacaoController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { RemoveVegetacaoUseCase } from '@/domain/vegetacao/RemoveVegetacaoUseCase'
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 { NotFoundError } from '@/library/http/error/NotFoundError'
import { NextHandler, RequestHandler } from '@/library/http/Server'

interface Dependencies {
removeVegetacaoUseCase: RemoveVegetacaoUseCase
}

export class RemoveVegetacaoController implements RequestHandler {
private readonly removeVegetacaoUseCase: RemoveVegetacaoUseCase

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

async handle(request: HttpRequest, _next: NextHandler): Promise<HttpResponse | HttpError> {
const { vegetacaoId } = request.params as { vegetacaoId?: string }

if (
vegetacaoId === undefined
|| vegetacaoId === null
|| vegetacaoId === ''
|| Number.isNaN(Number(vegetacaoId))
|| Number(vegetacaoId) <= 0
) {
return new BadRequestError({ message: 'vegetacaoId inválido' })
}

const result = await this.removeVegetacaoUseCase.execute({ id: Number(vegetacaoId) })

if (result.left()) {
if (result.value.message === 'Vegetação está em uso e não pode ser removida') {
return new ConflictError({ message: 'Vegetação está em uso e não pode ser removida' })
}
return new InternalServerError({ message: result.value.message })
}

if (!result.value) {
return new NotFoundError({ message: 'Vegetação não encontrada' })
}

return { statusCode: StatusCode.NoContent, body: undefined }
}
}
63 changes: 63 additions & 0 deletions src/application/vegetacao/RenomeiaVegetacaoController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { RenomeiaVegetacaoUseCase } from '@/domain/vegetacao/RenomeiaVegetacaoUseCase'
import { Vegetacao } from '@/domain/vegetacao/Vegetacao'
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 { NotFoundError } from '@/library/http/error/NotFoundError'
import { NextHandler, RequestHandler } from '@/library/http/Server'

interface Dependencies {
renomeiaVegetacaoUseCase: RenomeiaVegetacaoUseCase
}

export class RenomeiaVegetacaoController implements RequestHandler {
private readonly renomeiaVegetacaoUseCase: RenomeiaVegetacaoUseCase

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

async handle(request: HttpRequest, _next: NextHandler): Promise<HttpResponse | HttpError> {
const { vegetacaoId } = request.params as { vegetacaoId?: string }
const { nome } = request.body as { nome?: string }

if (
vegetacaoId === undefined
|| vegetacaoId === null
|| vegetacaoId === ''
|| Number.isNaN(Number(vegetacaoId))
|| Number(vegetacaoId) <= 0
) {
return new BadRequestError({ message: 'vegetacaoId inválido' })
}

if (typeof nome !== 'string' || !nome.trim()) {
return new BadRequestError({ message: 'Nome da vegetação não pode ser vazio' })
}

const parsedId = Number(vegetacaoId)
const created = Vegetacao.create({ id: parsedId, nome: nome.trim() })
if (created.left()) {
return new BadRequestError({ message: created.value.message })
}

const result = await this.renomeiaVegetacaoUseCase.execute({ id: parsedId, nome: nome.trim() })

if (result.left()) {
if (result.value.message === 'Já existe uma vegetação com esse nome') {
return new ConflictError({ message: 'Já existe uma vegetação com esse nome' })
}
return new InternalServerError({ message: result.value.message })
}

if (!result.value) {
return new NotFoundError({ message: 'Vegetação não encontrada' })
}

return { statusCode: StatusCode.Ok, body: result.value }
}
}
49 changes: 49 additions & 0 deletions src/application/vegetacao/RequerAcessoEscritaVegetacao.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import jwt from 'jsonwebtoken'

import { secret } from '@/config/security'
import {
HttpRequest,
HttpResponse
} from '@/library/http/common'
import { ForbiddenError } from '@/library/http/error/ForbiddenError'
import { HttpError } from '@/library/http/error/HttpError'
import { UnauthorizedError } from '@/library/http/error/UnauthorizedError'
import { NextHandler, RequestHandler } from '@/library/http/Server'

const ALLOWED_TIPOS_USUARIOS = new Set([1, 2])

export class ExigePermissaoEscritaVegetacao implements RequestHandler {
async handle(request: HttpRequest, next: NextHandler): Promise<HttpResponse | HttpError> {
const authorization = request.headers.Authorization ?? request.headers.authorization

if (!authorization || typeof authorization !== 'string' || !authorization.startsWith('Bearer ')) {
return new ForbiddenError({ message: 'Token de autenticação obrigatório' })
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Token ausente ou vazio retorna 403, papel diferente de 1|2 retorna 403, expirado ou inválido retorna 401. Os testes de POST/PUT/DELETE só enviam token de curador no caminho feliz; esses ramos novos não estão cobertos.


const token = authorization.slice('Bearer '.length).trim()
if (!token) {
return new ForbiddenError({ message: 'Token de autenticação obrigatório' })
}

try {
if (!secret) {
return new UnauthorizedError({ message: 'Token de autenticação inválido' })
}

const payload = jwt.verify(token, secret) as { tipo_usuario_id?: unknown }
const tipoUsuarioId = Number(payload.tipo_usuario_id)

if (!Number.isInteger(tipoUsuarioId) || !ALLOWED_TIPOS_USUARIOS.has(tipoUsuarioId)) {
return new ForbiddenError({ message: 'Usuário sem permissão para alterar vegetações' })
}

return next()
} catch (error) {
if (error instanceof Error && error.name === 'TokenExpiredError') {
return new UnauthorizedError({ message: 'Token expirado' })
}

return new UnauthorizedError({ message: 'Token de autenticação inválido' })
}
}
}
46 changes: 42 additions & 4 deletions src/application/vegetacao/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
import { type Knex } from 'knex'

import { BuscarVegetacaoPorIdUseCase } from '@/domain/vegetacao/BuscarVegetacaoPorIdUseCase'
import { BuscaVegetacaoPorIdUseCase } from '@/domain/vegetacao/BuscaVegetacaoPorIdUseCase'
import { CadastraVegetacaoUseCase } from '@/domain/vegetacao/CadastraVegetacaoUseCase'
import { ListaVegetacoesUseCase } from '@/domain/vegetacao/ListaVegetacoesUseCase'
import { RemoveVegetacaoUseCase } from '@/domain/vegetacao/RemoveVegetacaoUseCase'
import { RenomeiaVegetacaoUseCase } from '@/domain/vegetacao/RenomeiaVegetacaoUseCase'
import { VegetacaoCollectionKnexAdapter } from '@/infrastructure/VegetacaoCollectionKnexAdapter'
import { Method } from '@/library/http/common'
import { Route } from '@/library/http/Router'

import { BuscarVegetacaoController } from './BuscarVegetacaoController'
import { BuscaVegetacaoController } from './BuscaVegetacaoController'
import { CadastraVegetacaoController } from './CadastraVegetacaoController'
import { ListaVegetacoesController } from './ListaVegetacoesController'
import { RemoveVegetacaoController } from './RemoveVegetacaoController'
import { RenomeiaVegetacaoController } from './RenomeiaVegetacaoController'
import { ExigePermissaoEscritaVegetacao } from './RequerAcessoEscritaVegetacao'

export function routes(knex: Knex): Route[] {
const vegetacaoCollection = new VegetacaoCollectionKnexAdapter({ knex })
const exigePermissaoEscritaVegetacao = new ExigePermissaoEscritaVegetacao()

return [
{
handlers: [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

POST, PUT e DELETE montam só o controller em handlers. O legado POST /vegetacoes exige tokensMiddleware([CURADOR, OPERADOR]). País/estado v2 são só GET públicos e não justificam mutação anônima do catálogo. Os testes de escrita passam sem Authorization.

exigePermissaoEscritaVegetacao,
new CadastraVegetacaoController({
cadastraVegetacaoUseCase: new CadastraVegetacaoUseCase({ vegetacaoCollection })
})
],
method: Method.Post,
path: '/v2/vegetacoes'
},
{
handlers: [
new ListaVegetacoesController({
Expand All @@ -24,12 +42,32 @@ export function routes(knex: Knex): Route[] {
},
{
handlers: [
new BuscarVegetacaoController({
buscarVegetacaoPorIdUseCase: new BuscarVegetacaoPorIdUseCase({ vegetacaoCollection })
new BuscaVegetacaoController({
buscaVegetacaoPorIdUseCase: new BuscaVegetacaoPorIdUseCase({ vegetacaoCollection })
})
],
method: Method.Get,
path: '/v2/vegetacoes/:vegetacaoId'
},
{
handlers: [
exigePermissaoEscritaVegetacao,
new RenomeiaVegetacaoController({
renomeiaVegetacaoUseCase: new RenomeiaVegetacaoUseCase({ vegetacaoCollection })
})
],
method: Method.Put,
path: '/v2/vegetacoes/:vegetacaoId'
},
{
handlers: [
exigePermissaoEscritaVegetacao,
new RemoveVegetacaoController({
removeVegetacaoUseCase: new RemoveVegetacaoUseCase({ vegetacaoCollection })
})
],
method: Method.Delete,
path: '/v2/vegetacoes/:vegetacaoId'
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Knex } from 'knex'

export async function run(knex: Knex): Promise<void> {
const tableExists = await knex.schema.hasTable('vegetacoes')

if (!tableExists) {
return
}

const indexExists = await knex.raw(`
SELECT 1
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'vegetacoes'
AND indexname = 'vegetacoes_nome_unique'
LIMIT 1
`)

if (indexExists.rowCount === 0 || indexExists.rows?.length === 0) {
await knex.raw(`
CREATE UNIQUE INDEX IF NOT EXISTS vegetacoes_nome_unique
ON vegetacoes (LOWER(nome))
`)
}
}
20 changes: 20 additions & 0 deletions src/domain/vegetacao/BuscaVegetacaoPorIdUseCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Either } from '@/library/either/Either'

import { Attributes } from './Vegetacao'
import { VegetacaoCollection } from './VegetacaoCollection'

interface Dependencies {
vegetacaoCollection: VegetacaoCollection
}

export class BuscaVegetacaoPorIdUseCase {
private readonly vegetacaoCollection: VegetacaoCollection

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

execute({ id }: { id: number }): Promise<Either<Error, Attributes | null>> {
return this.vegetacaoCollection.findById(id)
}
}
Loading
Loading