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
32 changes: 32 additions & 0 deletions .cursor/rules/type-and-file-names.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
description: Type and file names use 3rd-person present verbs, not the infinitive, in Portuguese or English
globs: "{src,test}/**/*.{ts,js}"
alwaysApply: false
---

# Type and file names

Name new controllers, use cases, and their tests after the **action as a finite verb**: 3rd-person singular present (the form that agrees with “it does X”). Do not use the infinitive (“to X”) or the bare uninflected stem.

The same conjugation rule applies in Portuguese and in English. Keep the language of a module consistent; do not mix languages in one type or file name.

## How to name a type

1. Choose the action verb in the module’s language.
2. Inflect it to 3rd-person singular present.
3. Append the resource noun (plural for a collection, singular for one entity).
4. Append the role suffix (`Controller`, `UseCase`, and the same for other application/domain types).

| Role | Pattern |
| --- | --- |
| List a collection | `{Verb}{Resources}Controller` / `{Verb}{Resources}UseCase` |
| Get one by id | `{Verb}{Resource}UseCase` (and matching controller) |
| Tests | One file per action; kebab-case of the same verb + resource stem |

Type files are PascalCase and match the exported type. Test files use kebab-case of that stem (for example `lists-countries.test.ts`), not a different verb and not several actions in one file.

## When renaming

Delete the old file. Do not leave unused duplicates that use the infinitive or a different inflection.

Do not copy drifted names already in the tree. Match the inflection rule above for every new module.
10 changes: 4 additions & 6 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,14 @@ jobs:
PG_PORT: 5432
PG_DATABASE: herbario_test
PG_USERNAME: postgres
PG_PASSWORD: testpassword
PG_MIGRATION_USERNAME: postgres
PG_MIGRATION_PASSWORD: testpassword
PG_PASSWORD: secret
services:
postgres:
image: postgis/postgis:18-3.6
env:
POSTGRES_DB: herbario_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpassword
POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >-
Expand All @@ -74,9 +72,9 @@ jobs:
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Apply base schema
run: psql -h $PG_HOST -p $PG_PORT -U $PG_MIGRATION_USERNAME -d $PG_DATABASE -f test/integration/setup/schema.sql
run: psql -h $PG_HOST -p $PG_PORT -U $PG_USERNAME -d $PG_DATABASE -f test/integration/setup/schema.sql
env:
PGPASSWORD: ${{ env.PG_MIGRATION_PASSWORD }}
PGPASSWORD: ${{ env.PG_PASSWORD }}
- name: Run integration tests
run: yarn test:integration

Expand Down
6 changes: 3 additions & 3 deletions compose.integration.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
services:
postgres:
image: postgis/postgis:18-3.6
container_name: herbario_postgresql_e2e
container_name: herbario_postgresql_test
environment:
POSTGRES_DB: herbario_e2e
POSTGRES_DB: herbario_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpassword
POSTGRES_PASSWORD: secret
ports:
- "5433:5432"
volumes:
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
"build": "node build.mjs",
"test": "vitest --run",
"test:unit": "vitest --run --project unit",
"test:unit:watch": "vitest --project unit",
"test:unit:watch": "vitest --project unit",
"test:integration": "vitest --run --project integration",
"test:integration:watch": "vitest --project integration",
"test:integration:watch": "vitest --project integration",
"test:coverage": "vitest --run --project unit --coverage",
"prepare": "husky",
"audit": "npm audit --audit-level=moderate",
Expand Down
4 changes: 0 additions & 4 deletions script/database-sync/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,15 @@ RUN \
gnupg \
curl \
ca-certificates && \

curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql.gpg && \
echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \

apt-get update && \
apt-get install -y \
postgresql-client-18 && \

apt-get remove -y \
curl \
ca-certificates \
gnupg && \

rm -rf /var/lib/apt/lists/*

ENV TZ=America/Sao_Paulo
Expand Down
22 changes: 18 additions & 4 deletions script/database-sync/database-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ set -euo pipefail

echo "Starting synchronization process..."

exclude_args=()
if [[ -n "${SYNC_EXCLUDE_TABLES:-}" ]]; then
IFS=$' \t\n,' read -r -a exclude_tables <<< "$SYNC_EXCLUDE_TABLES"
for table in "${exclude_tables[@]}"; do
[[ -z "$table" ]] && continue
exclude_args+=(--exclude-table="$table" --exclude-table="${table}_id_seq")
done
echo "Excluding tables: ${exclude_tables[*]}"
fi

# Plain SQL so we can CASCADE drops. Destination excluded tables keep their
# rows; CASCADE only removes leftover FKs that would block --clean.
PGPASSWORD="$SYNC_SOURCE_PASSWORD" pg_dump \
-h "$SYNC_SOURCE_HOST" \
-p "$SYNC_SOURCE_PORT" \
Expand All @@ -13,13 +25,15 @@ PGPASSWORD="$SYNC_SOURCE_PASSWORD" pg_dump \
--no-privileges \
--clean \
--if-exists \
--format=c | \
PGPASSWORD="$SYNC_DEST_PASSWORD" pg_restore \
"${exclude_args[@]}" | \
sed -E \
-e 's/^(DROP TABLE IF EXISTS .+);$/\1 CASCADE;/' \
-e 's/^(ALTER TABLE .+ DROP CONSTRAINT IF EXISTS .+);$/\1 CASCADE;/' | \
PGPASSWORD="$SYNC_DEST_PASSWORD" psql \
-h "$SYNC_DEST_HOST" \
-p "$SYNC_DEST_PORT" \
-U "$SYNC_DEST_USER" \
-d "$SYNC_DEST_DATABASE" \
--clean \
--if-exists
-v ON_ERROR_STOP=1

echo "Synchronization completed successfully: ${SYNC_SOURCE_DATABASE} -> ${SYNC_DEST_DATABASE}"
1 change: 1 addition & 0 deletions script/database-sync/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ services:
SYNC_DEST_USER: "postgres"
SYNC_DEST_PASSWORD: "masterkey"
SYNC_DEST_DATABASE: "herbario_dev"
# SYNC_EXCLUDE_TABLES: "public.usuarios"
CRON_SCHEDULE: "*/2 * * * *"
TZ: "America/Sao_Paulo"
6 changes: 5 additions & 1 deletion src/application/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ 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 createFaseSucessionalRoutes } from './fase-sucessional'
import { routes as createPaisRoutes } from './pais'
import { routes as createVegetacaoRoutes } from './vegetacao'

interface CorsParameters {
origins: string[]
Expand Down Expand Up @@ -55,7 +57,9 @@ export function createApp({
}: Parameters) {
const routes: Route[] = [
...createPaisRoutes(knex),
...createEstadoRoutes(knex)
...createEstadoRoutes(knex),
...createFaseSucessionalRoutes(knex),
...createVegetacaoRoutes(knex)
]
const application = new ExpressApplication({ logger })

Expand Down
42 changes: 42 additions & 0 deletions src/application/fase-sucessional/BuscaFaseSucessionalController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { BuscaFaseSucessionalUseCase } from '@/domain/faseSucessional/BuscaFaseSucessionalUseCase'
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 {
buscaFaseSucessionalUseCase: BuscaFaseSucessionalUseCase
}

export class BuscaFaseSucessionalController implements RequestHandler {
private readonly buscaFaseSucessionalUseCase: BuscaFaseSucessionalUseCase

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

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

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

const id = Number(faseSucessionalId)
const result = await this.buscaFaseSucessionalUseCase.execute({ id })

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

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

return { statusCode: StatusCode.Ok, body: result.value }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { ListaFasesSucessionaisUseCase } from '@/domain/faseSucessional/ListaFasesSucessionaisUseCase'
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 {
listaFasesSucessionaisUseCase: ListaFasesSucessionaisUseCase
}

export class ListaFasesSucessionaisController implements RequestHandler {
private readonly listaFasesSucessionaisUseCase: ListaFasesSucessionaisUseCase

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

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

const parsedOrder = parseOrder(order)
if (parsedOrder instanceof Error) {
return new BadRequestError({ message: parsedOrder.message })
}

const result = await this.listaFasesSucessionaisUseCase.execute({
nome,
order: parsedOrder
})

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

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

function parseOrder(order?: string): { column: 'id' | 'nome'; direction: 'asc' | 'desc' } | Error | undefined {
if (!order) return undefined

const pieces = order.split(':')
if (pieces.length !== 2) {
return new Error('order inválido. Use o formato "id:asc", "id:desc", "nome:asc" ou "nome:desc"')
}

const [rawColumn, rawDirection] = pieces
const column = rawColumn.trim().toLowerCase()
const direction = rawDirection.trim().toLowerCase()

if ((column !== 'id' && column !== 'nome') || (direction !== 'asc' && direction !== 'desc')) {
return new Error('order inválido. Use o formato "id:asc", "id:desc", "nome:asc" ou "nome:desc"')
}

return {
column,
direction
}
}
35 changes: 35 additions & 0 deletions src/application/fase-sucessional/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { type Knex } from 'knex'

import { BuscaFaseSucessionalUseCase } from '@/domain/faseSucessional/BuscaFaseSucessionalUseCase'
import { ListaFasesSucessionaisUseCase } from '@/domain/faseSucessional/ListaFasesSucessionaisUseCase'
import { FaseSucessionalCollectionKnexAdapter } from '@/infrastructure/FaseSucessionalCollectionKnexAdapter'
import { Method } from '@/library/http/common'
import { Route } from '@/library/http/Router'

import { BuscaFaseSucessionalController } from './BuscaFaseSucessionalController'
import { ListaFasesSucessionaisController } from './ListaFasesSucessionaisController'

export function routes(knex: Knex): Route[] {
const faseSucessionalCollection = new FaseSucessionalCollectionKnexAdapter({ knex })

return [
{
handlers: [
new ListaFasesSucessionaisController({
listaFasesSucessionaisUseCase: new ListaFasesSucessionaisUseCase({ faseSucessionalCollection })
})
],
method: Method.Get,
path: '/v2/fases-sucessionais'
},
{
handlers: [
new BuscaFaseSucessionalController({
buscaFaseSucessionalUseCase: new BuscaFaseSucessionalUseCase({ faseSucessionalCollection })
})
],
method: Method.Get,
path: '/v2/fases-sucessionais/:faseSucessionalId'
}
]
}
41 changes: 41 additions & 0 deletions src/application/vegetacao/BuscarVegetacaoController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { BuscarVegetacaoPorIdUseCase } from '@/domain/vegetacao/BuscarVegetacaoPorIdUseCase'
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 {
buscarVegetacaoPorIdUseCase: BuscarVegetacaoPorIdUseCase
}

export class BuscarVegetacaoController implements RequestHandler {
private readonly buscarVegetacaoPorIdUseCase: BuscarVegetacaoPorIdUseCase

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

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.buscarVegetacaoPorIdUseCase.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 }
}
}
Loading
Loading