-
Notifications
You must be signed in to change notification settings - Fork 2
Feat: Cadastrar, Renomear e Remover tipos de vegetações #525
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from all commits
867921f
5e8c7aa
a8db337
b334e25
a14b7ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 } | ||
| } | ||
| } |
| 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 } | ||
| } | ||
| } |
| 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 } | ||
| } | ||
| } |
| 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 } | ||
| } | ||
| } |
| 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 ')) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 escrita cobrem o happy path e JWT_SECRET ausente; esses ramos ainda não estão cobertos. |
||
| return new ForbiddenError({ message: 'Token de autenticação obrigatório' }) | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' }) | ||
| } | ||
| } | ||
| } | ||
| 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: [ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. POST, PUT e DELETE montam só o controller em |
||
| exigePermissaoEscritaVegetacao, | ||
| new CadastraVegetacaoController({ | ||
| cadastraVegetacaoUseCase: new CadastraVegetacaoUseCase({ vegetacaoCollection }) | ||
| }) | ||
| ], | ||
| method: Method.Post, | ||
| path: '/v2/vegetacoes' | ||
| }, | ||
| { | ||
| handlers: [ | ||
| new ListaVegetacoesController({ | ||
|
|
@@ -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)) | ||
| `) | ||
| } | ||
| } |
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
O arquivo RequerAcessoEscritaVegetacao.ts exporta ExigePermissaoEscritaVegetacao. O nome do arquivo deve coincidir com o tipo exportado: renomear para ExigePermissaoEscritaVegetacao.ts e ajustar o import em index.ts.