Uma solução backend robusta, escalável e sustentável para gestão organizacional completa
📚 Documentação • 🚀 Início Rápido • 🏗️ Arquitetura • 🔧 API Reference • 🧪 Testes
- Sobre o Projeto
- Características Principais
- Stack Tecnológica
- Arquitetura
- Início Rápido
- Configuração
- Modelo de Dados
- API Reference
- Autenticação
- Testes
- Deploy
- Performance
- Segurança
- Contribuindo
- Roadmap
- Licença
A API Sistema GestãoInt é uma solução backend de nível empresarial construída com as melhores práticas de desenvolvimento moderno. Projetada para oferecer uma infraestrutura completa de gestão organizacional interna, a API combina Clean Architecture, Domain-Driven Design (DDD) e SOLID principles para garantir código sustentável, testável e escalável.
- 🏢 Gestão Empresarial - Controle completo de empresas e filiais
- 👥 Gestão de Usuários - Sistema robusto de autenticação e autorização
- 💰 Movimentações Financeiras - Controle de entradas e saídas
- 📦 Gestão de Produtos - Catálogo e inventário
- 📊 Relatórios e Analytics - Insights em tempo real
- 🔔 Notificações - Sistema de eventos e alertas
- 📈 Planos e Assinaturas - Gestão de planos empresariais
- ✅ Clean Architecture - Separação clara de responsabilidades
- ✅ Domain-Driven Design (DDD) - Modelagem focada no domínio
- ✅ SOLID Principles - Código sustentável e extensível
- ✅ CQRS Pattern - Separação de comandos e queries
- ✅ Event-Driven - Arquitetura orientada a eventos
- ✅ Dependency Injection - Baixo acoplamento
- 🛡️ JWT Authentication - Tokens seguros e stateless
- 🔐 Argon2 Hashing - Algoritmo de hash resistente
- 🚦 Rate Limiting - Proteção contra abuso
- 🪖 Helmet.js - Headers de segurança HTTP
- 🔍 Input Validation - Validação com Zod
- 🚪 Guards & Interceptors - Controle de acesso granular
- ⚡ Fastify - Servidor HTTP de alta performance
- 💾 Redis Cache - Cache distribuído
- 📊 Query Optimization - Prisma ORM otimizado
- 🔄 Connection Pooling - Gerenciamento eficiente de conexões
- 📦 Compression - Compressão de respostas
- 🎯 Response Time - < 200ms em média
- ✅ Unit Tests - Testes unitários com Jest
- ✅ Integration Tests - Testes de integração
- ✅ E2E Tests - Testes end-to-end
- ✅ Architecture Tests - Validação de arquitetura
- ✅ Code Coverage - +80% de cobertura
- ✅ CI/CD Pipeline - GitHub Actions
- 🌐 REST API - Endpoints RESTful documentados
- 🔮 GraphQL API - Queries flexíveis com Apollo
- 📖 OpenAPI/Swagger - Documentação interativa
- 🔄 Real-time - WebSockets e eventos
- 📨 Background Jobs - BullMQ para tarefas assíncronas
| Tecnologia | Versão | Descrição |
|---|---|---|
| Node.js | ≥18.x | Runtime JavaScript de alta performance |
| NestJS | ^11.0.1 | Framework progressivo para Node.js |
| TypeScript | ^5.7.3 | Superset tipado de JavaScript |
| Fastify | ^4.29.1 | Web framework extremamente rápido |
| Tecnologia | Versão | Função |
|---|---|---|
| PostgreSQL | ≥14.x | Banco de dados relacional |
| Prisma | ^6.13.0 | ORM de próxima geração |
| Redis | ^5.5.6 | Cache e message broker |
| Tecnologia | Versão | Propósito |
|---|---|---|
| GraphQL | ^16.11.0 | Query language para APIs |
| Apollo Server | ^3.13.0 | Servidor GraphQL |
| Swagger/OpenAPI | ^11.2.0 | Documentação de API |
| Tecnologia | Versão | Uso |
|---|---|---|
| Passport | ^0.7.0 | Middleware de autenticação |
| JWT | ^11.0.0 | JSON Web Tokens |
| Argon2 | ^0.43.1 | Hashing de senhas |
| Helmet | ^8.1.0 | Segurança HTTP headers |
| Throttler | ^6.4.0 | Rate limiting |
| Tecnologia | Versão | Função |
|---|---|---|
| BullMQ | ^5.54.2 | Gerenciamento de filas |
| Event Emitter | ^3.0.1 | Sistema de eventos |
| CQRS | ^11.0.3 | Command Query Responsibility Segregation |
| Tecnologia | Versão | Propósito |
|---|---|---|
| Jest | ^29.7.0 | Framework de testes |
| Supertest | ^7.0.0 | Testes HTTP |
| ESLint | ^9.18.0 | Linter de código |
| Prettier | ^3.4.2 | Formatador de código |
| Tecnologia | Versão | Uso |
|---|---|---|
| Zod | ^3.25.63 | Validação de schemas |
| date-fns | ^4.1.0 | Manipulação de datas |
| Winston | ^3.17.0 | Sistema de logs |
| Nodemailer | ^7.0.3 | Envio de emails |
A aplicação segue os princípios de Clean Architecture e Domain-Driven Design, garantindo separação clara de responsabilidades e independência de frameworks.
┌─────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ REST API │ │ GraphQL API │ │ WebSockets │ │
│ │ Controllers │ │ Resolvers │ │ Gateways │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Use Cases │ │ Commands │ │ Queries │ │
│ │ Services │ │ Handlers │ │ Handlers │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ DOMAIN LAYER │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Entities │ │ Value │ │ Domain │ │
│ │ Aggregates │ │ Objects │ │ Events │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ INFRASTRUCTURE LAYER │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Database │ │ Cache │ │ External │ │
│ │ Repositories │ │ Redis │ │ Services │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
src/
├── 🏗️ core/ # DOMAIN LAYER
│ ├── entities/ # Entidades de negócio
│ │ ├── user.entity.ts
│ │ ├── company.entity.ts
│ │ └── cash-movement.entity.ts
│ ├── use-cases/ # Casos de uso
│ │ ├── create-user/
│ │ ├── authenticate-user/
│ │ └── register-movement/
│ ├── ports/ # Interfaces/Contratos
│ │ ├── repositories/
│ │ └── services/
│ ├── dtos/ # Data Transfer Objects
│ └── exceptions/ # Exceções de domínio
│
├── 🔧 infra/ # INFRASTRUCTURE LAYER
│ ├── database/ # Implementações de BD
│ │ ├── prisma/
│ │ ├── repositories/
│ │ └── migrations/
│ ├── services/ # Serviços externos
│ │ ├── email/
│ │ └── storage/
│ ├── cache/ # Redis cache
│ ├── graphql/ # GraphQL schemas
│ │ ├── resolvers/
│ │ └── types/
│ └── filters/ # Exception filters
│
├── 🌐 modules/ # PRESENTATION LAYER
│ ├── auth/ # Autenticação
│ │ ├── controllers/
│ │ ├── guards/
│ │ ├── strategies/
│ │ └── dto/
│ ├── user/ # Gestão de usuários
│ │ ├── controllers/
│ │ ├── services/
│ │ └── dto/
│ ├── company/ # Gestão de empresas
│ │ ├── controllers/
│ │ ├── services/
│ │ └── dto/
│ ├── cash-movement/ # Movimentações
│ │ ├── controllers/
│ │ ├── services/
│ │ └── dto/
│ ├── product/ # Produtos
│ └── category/ # Categorias
│
├── 🛠️ shared/ # CROSS-CUTTING CONCERNS
│ ├── guards/ # Security guards
│ │ ├── jwt-auth.guard.ts
│ │ └── roles.guard.ts
│ ├── interceptors/ # Interceptors
│ │ ├── logging.interceptor.ts
│ │ └── transform.interceptor.ts
│ ├── decorators/ # Custom decorators
│ ├── dto/ # DTOs compartilhados
│ ├── utils/ # Utilitários
│ └── swagger/ # Configuração Swagger
│
├── 🧪 tests__/ # TEST SUITES
│ ├── unit/ # Testes unitários
│ ├── integration/ # Testes de integração
│ ├── e2e/ # End-to-end tests
│ └── architecture/ # Testes arquiteturais
│
├── app.module.ts # Módulo raiz
└── main.ts # Entry point
Cada classe tem uma única responsabilidade bem definida.
// ✅ BOM: Responsabilidade única
class UserRepository {
async findById(id: string): Promise<User> { }
}
class UserValidator {
validate(user: User): ValidationResult { }
}Aberto para extensão, fechado para modificação.
// ✅ BOM: Extensível via interface
interface PaymentStrategy {
process(amount: number): Promise<void>;
}
class CreditCardPayment implements PaymentStrategy { }
class PixPayment implements PaymentStrategy { }Subtipos devem ser substituíveis por seus tipos base.
// ✅ BOM: Substituível
abstract class Repository<T> {
abstract findById(id: string): Promise<T>;
}
class UserRepository extends Repository<User> { }Interfaces específicas são melhores que interfaces gerais.
// ✅ BOM: Interfaces segregadas
interface Readable { read(): Promise<Data>; }
interface Writable { write(data: Data): Promise<void>; }
interface Deletable { delete(id: string): Promise<void>; }Dependa de abstrações, não de implementações concretas.
// ✅ BOM: Depende de abstração
class UserService {
constructor(
private readonly userRepository: IUserRepository, // Interface
private readonly emailService: IEmailService, // Interface
) {}
}Certifique-se de ter instalado:
- Node.js ≥ 18.x (Download)
- npm ou yarn ou pnpm
- PostgreSQL ≥ 14.x (Download)
- Redis ≥ 6.x (Download)
- Docker (opcional, mas recomendado) (Download)
# 1. Clone o repositório
git clone https://github.com/Joaoof/api-sistema-gestaoint.git
cd api-sistema-gestaoint
# 2. Instale as dependências
npm install
# ou
yarn install
# ou
pnpm install
# 3. Configure as variáveis de ambiente
cp .env.example .env
# Edite o arquivo .env com suas configurações
# 4. Execute as migrações do banco de dados
npx prisma migrate dev
# 5. (Opcional) Seed do banco de dados
npx prisma db seed
# 6. Inicie o servidor de desenvolvimento
npm run start:dev# 1. Clone o repositório
git clone https://github.com/Joaoof/api-sistema-gestaoint.git
cd api-sistema-gestaoint
# 2. Configure as variáveis de ambiente
cp .env.example .env
# 3. Inicie os containers
docker-compose up -d
# 4. Execute as migrações
docker-compose exec api npx prisma migrate dev
# 5. Acesse a aplicação
# API: http://localhost:3000
# Swagger: http://localhost:3000/api
# GraphQL Playground: http://localhost:3000/graphql# Verifique se a API está rodando
curl http://localhost:3000/health
# Resposta esperada:
# {"status":"ok","timestamp":"2025-09-30T12:00:00.000Z"}Crie um arquivo .env na raiz do projeto:
# Application
NODE_ENV=development
PORT=3000
API_PREFIX=api
API_VERSION=v1
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/gestaoint?schema=public"
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRATION=7d
JWT_REFRESH_SECRET=your-refresh-secret-key
JWT_REFRESH_EXPIRATION=30d
# Rate Limiting
THROTTLE_TTL=60
THROTTLE_LIMIT=10
# Email (Nodemailer)
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USER=your-email@gmail.com
MAIL_PASSWORD=your-app-password
MAIL_FROM=noreply@gestaoint.com
# AWS S3 (opcional)
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_S3_BUCKET=your-bucket-name
# Monitoring (opcional)
SENTRY_DSN=your-sentry-dsn
# Logs
LOG_LEVEL=debug# Criar banco de dados
createdb gestaoint
# Executar migrações
npx prisma migrate dev
# Visualizar banco de dados
npx prisma studio# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:14-alpine
container_name: gestaoint-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gestaoint
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
container_name: gestaoint-redis
ports:
- '6379:6379'
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:# Criar uma nova migração
npx prisma migrate dev --name add_new_feature
# Aplicar migrações em produção
npx prisma migrate deploy
# Resetar banco de dados (CUIDADO!)
npx prisma migrate reset
# Gerar Prisma Client
npx prisma generate
# Visualizar banco de dados
npx prisma studio┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Company │────────<│ Users │>────────│ CashMovement│
│ │ │ │ │ │
│ id │ │ id │ │ id │
│ name │ │ email │ │ type │
│ cnpj │ │ password │ │ category │
│ email │ │ name │ │ value │
│ phone │ │ role │ │ description │
│ address │ │ company_id │ │ user_id │
│ logoUrl │ │ is_active │ │ date │
│ is_active │ │ createdAt │ │ createdAt │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ CompanyPlan │ │ Product │
│ │ │ │
│ id │ │ id │
│ company_id │ │ name │
│ plan_type │ │ description │
│ start_date │ │ price │
│ end_date │ │ category_id │
│ is_active │ │ user_id │
└─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ Category │
│ │
│ id │
│ name │
│ description │
│ user_id │
└─────────────┘
model Users {
id String @id @default(cuid())
email String @unique
password_hash String
name String
company_id String?
role String @default("USER")
is_active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relacionamentos
company Company? @relation(fields: [company_id], references: [id])
cashMovements CashMovement[]
products Product[]
categories Category[]
@@index([email])
@@index([company_id])
}model Company {
id String @id @default(cuid())
name String @unique
email String? @unique
phone String?
address String?
cnpj String? @unique
logoUrl String?
is_active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relacionamentos
users Users[]
companyPlan CompanyPlan?
@@index([cnpj])
}enum MovementType {
ENTRY
EXIT
}
enum MovementCategory {
SALE
PURCHASE
EXPENSE
INVESTMENT
TRANSFER
OTHER
}
model CashMovement {
id String @id @default(uuid())
type MovementType
category MovementCategory
value Decimal @db.Decimal(10, 2)
description String?
date DateTime @default(now())
user_id String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relacionamentos
user Users @relation(fields: [user_id], references: [id])
@@index([user_id])
@@index([date])
@@index([type])
}model Product {
id String @id @default(cuid())
name String
description String?
price Decimal @db.Decimal(10, 2)
stock Int @default(0)
category_id String?
user_id String
is_active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relacionamentos
category Category? @relation(fields: [category_id], references: [id])
user Users @relation(fields: [user_id], references: [id])
@@index([category_id])
@@index([user_id])
}model Category {
id String @id @default(cuid())
name String
description String?
user_id String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relacionamentos
products Product[]
user Users @relation(fields: [user_id], references: [id])
@@index([user_id])
}enum PlanType {
FREE
BASIC
PREMIUM
ENTERPRISE
}
model CompanyPlan {
id String @id @default(cuid())
company_id String @unique
plan_type PlanType @default(FREE)
start_date DateTime @default(now())
end_date DateTime?
is_active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relacionamentos
company Company @relation(fields: [company_id], references: [id])
@@index([company_id])
}A API REST está disponível em http://localhost:3000/api e documentada com Swagger.
http://localhost:3000/api/v1
Todas as rotas protegidas requerem um token JWT no header:
Authorization: Bearer <seu-token-jwt>Registra um novo usuário.
POST /api/v1/auth/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!",
"name": "João Silva",
"company_id": "optional-company-id"
}Response 201:
{
"user": {
"id": "clx1234567890",
"email": "user@example.com",
"name": "João Silva",
"role": "USER",
"is_active": true,
"createdAt": "2025-09-30T12:00:00.000Z"
},
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Autentica um usuário existente.
POST /api/v1/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!"
}Response 200:
{
"user": {
"id": "clx1234567890",
"email": "user@example.com",
"name": "João Silva",
"role": "USER"
},
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Renova o access token usando o refresh token.
POST /api/v1/auth/refresh
Content-Type: application/json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Invalida o token atual (requer autenticação).
POST /api/v1/auth/logout
Authorization: Bearer <token>Lista todos os usuários (requer autenticação).
GET /api/v1/users?page=1&limit=10&search=joão
Authorization: Bearer <token>Query Parameters:
page(opcional): Número da página (default: 1)limit(opcional): Itens por página (default: 10)search(opcional): Busca por nome ou email
Response 200:
{
"data": [
{
"id": "clx1234567890",
"email": "user@example.com",
"name": "João Silva",
"role": "USER",
"is_active": true,
"company": {
"id": "cly9876543210",
"name": "Empresa XYZ"
},
"createdAt": "2025-09-30T12:00:00.000Z"
}
],
"meta": {
"total": 100,
"page": 1,
"limit": 10,
"totalPages": 10
}
}Busca um usuário por ID.
GET /api/v1/users/clx1234567890
Authorization: Bearer <token>Atualiza um usuário.
PATCH /api/v1/users/clx1234567890
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "João Silva Santos",
"role": "ADMIN"
}Remove um usuário (soft delete).
DELETE /api/v1/users/clx1234567890
Authorization: Bearer <token>Cria uma nova empresa.
POST /api/v1/companies
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "Empresa XYZ Ltda",
"email": "contato@empresaxyz.com",
"phone": "+55 11 98765-4321",
"cnpj": "12.345.678/0001-90",
"address": "Rua Exemplo, 123 - São Paulo, SP"
}Lista todas as empresas.
GET /api/v1/companies?page=1&limit=10
Authorization: Bearer <token>Busca uma empresa por ID.
GET /api/v1/companies/cly9876543210
Authorization: Bearer <token>Atualiza uma empresa.
PATCH /api/v1/companies/cly9876543210
Authorization: Bearer <token>
Content-Type: application/json
{
"phone": "+55 11 91234-5678",
"logoUrl": "https://example.com/logo.png"
}Registra uma nova movimentação financeira.
POST /api/v1/cash-movements
Authorization: Bearer <token>
Content-Type: application/json
{
"type": "ENTRY",
"category": "SALE",
"value": 1500.00,
"description": "Venda de produto X",
"date": "2025-09-30T14:30:00.000Z"
}Lista movimentações com filtros.
GET /api/v1/cash-movements?type=ENTRY&startDate=2025-09-01&endDate=2025-09-30
Authorization: Bearer <token>Query Parameters:
type: ENTRY | EXITcategory: SALE | PURCHASE | EXPENSE | INVESTMENT | TRANSFER | OTHERstartDate: Data inicial (ISO 8601)endDate: Data final (ISO 8601)page: Número da páginalimit: Itens por página
Retorna resumo financeiro.
GET /api/v1/cash-movements/summary?startDate=2025-09-01&endDate=2025-09-30
Authorization: Bearer <token>Response 200:
{
"period": {
"startDate": "2025-09-01T00:00:00.000Z",
"endDate": "2025-09-30T23:59:59.999Z"
},
"summary": {
"totalEntries": 15000.00,
"totalExits": 8500.00,
"balance": 6500.00,
"entriesCount": 25,
"exitsCount": 18
},
"byCategory": {
"SALE": 12000.00,
"EXPENSE": 5000.00,
"INVESTMENT": 3500.00
}
}Cria um novo produto.
POST /api/v1/products
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "Produto Exemplo",
"description": "Descrição detalhada do produto",
"price": 99.90,
"stock": 100,
"category_id": "cat123456"
}Lista produtos com filtros.
GET /api/v1/products?category_id=cat123456&search=exemplo&page=1&limit=20
Authorization: Bearer <token>Busca um produto por ID.
GET /api/v1/products/prod123456
Authorization: Bearer <token>Atualiza um produto.
PATCH /api/v1/products/prod123456
Authorization: Bearer <token>
Content-Type: application/json
{
"price": 89.90,
"stock": 150
}Remove um produto.
DELETE /api/v1/products/prod123456
Authorization: Bearer <token>A API GraphQL está disponível em http://localhost:3000/graphql.
# Buscar usuário por ID
query GetUser($id: String!) {
user(id: $id) {
id
email
name
role
is_active
company {
id
name
cnpj
}
createdAt
}
}
# Listar produtos
query ListProducts($filter: ProductFilterInput, $pagination: PaginationInput) {
products(filter: $filter, pagination: $pagination) {
data {
id
name
description
price
stock
category {
id
name
}
}
meta {
total
page
limit
totalPages
}
}
}
# Resumo financeiro
query CashMovementSummary($startDate: DateTime!, $endDate: DateTime!) {
cashMovementSummary(startDate: $startDate, endDate: $endDate) {
totalEntries
totalExits
balance
byCategory {
category
total
}
}
}
Repository search completeRepository details fetchedFiles fetchedThought for 5sVou criar um README avançado e profissional para sua API! Baseado na análise do repositório, vou elaborar uma documentação completa e detalhada.
```markdown
<div align="center">
## 🔧 API Reference
### **📡 REST API**
A API REST está disponível em `http://localhost:3000/api` e documentada com Swagger.
#### **Base URL**
#### **Autenticação**
Todas as rotas protegidas requerem um token JWT no header:
```http
Authorization: Bearer <seu-token-jwt>
Registra um novo usuário.
POST /api/v1/auth/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!",
"name": "João Silva",
"company_id": "optional-company-id"
}Response 201:
{
"user": {
"id": "clx1234567890",
"email": "user@example.com",
"name": "João Silva",
"role": "USER",
"is_active": true,
"createdAt": "2025-09-30T12:00:00.000Z"
},
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Autentica um usuário existente.
POST /api/v1/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!"
}Response 200:
{
"user": {
"id": "clx1234567890",
"email": "user@example.com",
"name": "João Silva",
"role": "USER"
},
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Renova o access token usando o refresh token.
POST /api/v1/auth/refresh
Content-Type: application/json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Invalida o token atual (requer autenticação).
POST /api/v1/auth/logout
Authorization: Bearer <token>Lista todos os usuários (requer autenticação).
GET /api/v1/users?page=1&limit=10&search=joão
Authorization: Bearer <token>Query Parameters:
page(opcional): Número da página (default: 1)limit(opcional): Itens por página (default: 10)search(opcional): Busca por nome ou email
Response 200:
{
"data": [
{
"id": "clx1234567890",
"email": "user@example.com",
"name": "João Silva",
"role": "USER",
"is_active": true,
"company": {
"id": "cly9876543210",
"name": "Empresa XYZ"
},
"createdAt": "2025-09-30T12:00:00.000Z"
}
],
"meta": {
"total": 100,
"page": 1,
"limit": 10,
"totalPages": 10
}
}Busca um usuário por ID.
GET /api/v1/users/clx1234567890
Authorization: Bearer <token>Atualiza um usuário.
PATCH /api/v1/users/clx1234567890
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "João Silva Santos",
"role": "ADMIN"
}Remove um usuário (soft delete).
DELETE /api/v1/users/clx1234567890
Authorization: Bearer <token>Cria uma nova empresa.
POST /api/v1/companies
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "Empresa XYZ Ltda",
"email": "contato@empresaxyz.com",
"phone": "+55 11 98765-4321",
"cnpj": "12.345.678/0001-90",
"address": "Rua Exemplo, 123 - São Paulo, SP"
}Lista todas as empresas.
GET /api/v1/companies?page=1&limit=10
Authorization: Bearer <token>Busca uma empresa por ID.
GET /api/v1/companies/cly9876543210
Authorization: Bearer <token>Atualiza uma empresa.
PATCH /api/v1/companies/cly9876543210
Authorization: Bearer <token>
Content-Type: application/json
{
"phone": "+55 11 91234-5678",
"logoUrl": "https://example.com/logo.png"
}Registra uma nova movimentação financeira.
POST /api/v1/cash-movements
Authorization: Bearer <token>
Content-Type: application/json
{
"type": "ENTRY",
"category": "SALE",
"value": 1500.00,
"description": "Venda de produto X",
"date": "2025-09-30T14:30:00.000Z"
}Lista movimentações com filtros.
GET /api/v1/cash-movements?type=ENTRY&startDate=2025-09-01&endDate=2025-09-30
Authorization: Bearer <token>Query Parameters:
type: ENTRY | EXITcategory: SALE | PURCHASE | EXPENSE | INVESTMENT | TRANSFER | OTHERstartDate: Data inicial (ISO 8601)endDate: Data final (ISO 8601)page: Número da páginalimit: Itens por página
Retorna resumo financeiro.
GET /api/v1/cash-movements/summary?startDate=2025-09-01&endDate=2025-09-30
Authorization: Bearer <token>Response 200:
{
"period": {
"startDate": "2025-09-01T00:00:00.000Z",
"endDate": "2025-09-30T23:59:59.999Z"
},
"summary": {
"totalEntries": 15000.00,
"totalExits": 8500.00,
"balance": 6500.00,
"entriesCount": 25,
"exitsCount": 18
},
"byCategory": {
"SALE": 12000.00,
"EXPENSE": 5000.00,
"INVESTMENT": 3500.00
}
}Cria um novo produto.
POST /api/v1/products
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "Produto Exemplo",
"description": "Descrição detalhada do produto",
"price": 99.90,
"stock": 100,
"category_id": "cat123456"
}Lista produtos com filtros.
GET /api/v1/products?category_id=cat123456&search=exemplo&page=1&limit=20
Authorization: Bearer <token>Busca um produto por ID.
GET /api/v1/products/prod123456
Authorization: Bearer <token>Atualiza um produto.
PATCH /api/v1/products/prod123456
Authorization: Bearer <token>
Content-Type: application/json
{
"price": 89.90,
"stock": 150
}Remove um produto.
DELETE /api/v1/products/prod123456
Authorization: Bearer <token>A API GraphQL está disponível em http://localhost:3000/graphql.
# Buscar usuário por ID
query GetUser($id: String!) {
user(id: $id) {
id
email
name
role
is_active
company {
id
name
cnpj
}
createdAt
}
}
# Listar produtos
query ListProducts($filter: ProductFilterInput, $pagination: PaginationInput) {
products(filter: $filter, pagination: $pagination) {
data {
id
name
description
price
stock
category {
id
name
}
}
meta {
total
page
limit
totalPages
}
}
}
# Resumo financeiro
query CashMovementSummary($startDate: DateTime!, $endDate: DateTime!) {
cashMovementSummary(startDate: $startDate, endDate: $endDate) {
totalEntries
totalExits
balance
byCategory {
category
total
}
}
}# Criar usuário
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
email
name
role
}
}
# Registrar movimentação
mutation CreateCashMovement($input: CreateCashMovementInput!) {
createCashMovement(input: $input) {
id
type
category
value
description
date
}
}
# Atualizar produto
mutation UpdateProduct($id: String!, $input: UpdateProductInput!) {
updateProduct(id: $id, input: $input) {
id
name
price
stock
}
}Acesse a documentação interativa completa em:
http://localhost:3000/api
A documentação Swagger inclui:
- ✅ Todos os endpoints disponíveis
- ✅ Schemas de request/response
- ✅ Exemplos de uso
- ✅ Teste interativo de APIs
- ✅ Autenticação JWT integrada
A API utiliza JWT para autenticação stateless e segura.
┌─────────┐ ┌─────────┐ ┌──────────┐
│ Cliente │ │ API │ │ Database │
└────┬────┘ └────┬────┘ └────┬─────┘
│ │ │
│ POST /auth/login │ │
│ ────────────────────────>│ │
│ │ Buscar usuário │
│ │ ────────────────────────>│
│ │ │
│ │ Retornar usuário │
│ │ <────────────────────────│
│ │ │
│ │ Verificar senha │
│ │ (Argon2) │
│ │ │
│ Retornar tokens │ │
│ <────────────────────────│ │
│ - access_token │ │
│ - refresh_token │ │
│ │ │
│ GET /users (com token) │ │
│ ────────────────────────>│ │
│ │ Validar JWT │
│ │ │
│ │ Buscar dados │
│ │ ────────────────────────>│
│ │ │
│ Retornar dados │ │
│ <────────────────────────│ │
{
"header": {
"alg": "HS256",
"typ": "JWT"
},
"payload": {
"sub": "clx1234567890",
"email": "user@example.com",
"role": "USER",
"iat": 1727697600,
"exp": 1728302400
},
"signature": "..."
}// No cliente (exemplo com Axios)
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:3000/api/v1',
});
// Interceptor para adicionar token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Fazer requisição autenticada
const response = await api.get('/users/me');A API utiliza Argon2 para hashing de senhas, considerado o algoritmo mais seguro atualmente.
import * as argon2 from 'argon2';
// Hash de senha
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 2 ** 16,
timeCost: 3,
parallelism: 1,
});
// Verificação de senha
const isValid = await argon2.verify(hash, password);Proteção contra abuso com rate limiting configurável:
// Configuração padrão
{
ttl: 60, // 60 segundos
limit: 10, // 10 requisições
}
// Endpoints de autenticação (mais restritivo)
{
ttl: 60,
limit: 5, // 5 tentativas por minuto
}// Proteger rota com autenticação
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req) {
return req.user;
}
// Proteger rota com role específica
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Delete('users/:id')
deleteUser(@Param('id') id: string) {
return this.usersService.remove(id);
}A aplicação mantém +80% de cobertura de código com testes em múltiplas camadas.
# Executar todos os testes
npm run test
# Testes com cobertura
npm run test:cov
# Testes em modo watch
npm run test:watch
# Testes E2E
npm run test:e2e
# Testes de debug
npm run test:debugTestes de unidades isoladas (funções, classes, métodos).
// user.service.spec.ts
describe('UserService', () => {
let service: UserService;
let repository: MockType<UserRepository>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: UserRepository,
useFactory: repositoryMockFactory,
},
],
}).compile();
service = module.get<UserService>(UserService);
repository = module.get(UserRepository);
});
it('should create a user', async () => {
const createUserDto = {
email: 'test@example.com',
password: 'password123',
name: 'Test User',
};
repository.create.mockReturnValue(createUserDto);
const result = await service.create(createUserDto);
expect(result).toEqual(createUserDto);
expect(repository.create).toHaveBeenCalledWith(createUserDto);
});
});Testes de integração entre componentes.
// auth.integration.spec.ts
describe('Auth Integration', () => {
let app: INestApplication;
let prisma: PrismaService;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
prisma = moduleFixture.get<PrismaService>(PrismaService);
await app.init();
});
it('should register and login a user', async () => {
// Register
const registerResponse = await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({
email: 'integration@test.com',
password: 'SecurePass123!',
name: 'Integration Test',
})
.expect(201);
expect(registerResponse.body).toHaveProperty('access_token');
// Login
const loginResponse = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({
email: 'integration@test.com',
password: 'SecurePass123!',
})
.expect(200);
expect(loginResponse.body).toHaveProperty('access_token');
});
afterAll(async () => {
await prisma.$disconnect();
await app.close();
});
});Testes end-to-end simulando fluxos completos.
// cash-movement.e2e-spec.ts
describe('Cash Movement E2E', () => {
let app: INestApplication;
let accessToken: string;
beforeAll(async () => {
// Setup app and authenticate
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
// Login to get token
const loginResponse = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({
email: 'test@example.com',
password: 'password123',
});
accessToken = loginResponse.body.access_token;
});
it('should create, list, and get cash movement', async () => {
// Create
const createResponse = await request(app.getHttpServer())
.post('/api/v1/cash-movements')
.set('Authorization', `Bearer ${accessToken}`)
.send({
type: 'ENTRY',
category: 'SALE',
value: 1500.00,
description: 'Test sale',
})
.expect(201);
const movementId = createResponse.body.id;
// List
await request(app.getHttpServer())
.get('/api/v1/cash-movements')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeInstanceOf(Array);
expect(res.body.data.length).toBeGreaterThan(0);
});
// Get by ID
await request(app.getHttpServer())
.get(`/api/v1/cash-movements/${movementId}`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.expect((res) => {
expect(res.body.id).toBe(movementId);
expect(res.body.value).toBe('1500.00');
});
});
afterAll(async () => {
await app.close();
});
});Testes que validam a arquitetura do projeto.
// architecture.spec.ts
describe('Architecture Tests', () => {
it('should follow clean architecture layers', () => {
// Core não deve depender de infra ou modules
const coreFiles = glob.sync('src/core/**/*.ts');
coreFiles.forEach((file) => {
const content = fs.readFileSync(file, 'utf-8');
expect(content).not.toMatch(/from ['"].*/infra//);
expect(content).not.toMatch(/from ['"].*/modules//);
});
});
it('should use dependency injection', () => {
// Services devem usar @Injectable()
const serviceFiles = glob.sync('src/**/*.service.ts');
serviceFiles.forEach((file) => {
const content = fs.readFileSync(file, 'utf-8');
expect(content).toMatch(/@Injectable()/);
});
});
it('should have DTOs for all endpoints', () => {
// Controllers devem usar DTOs
const controllerFiles = glob.sync('src/**/*.controller.ts');
controllerFiles.forEach((file) => {
const content = fs.readFileSync(file, 'utf-8');
if (content.match(/@Post(|@Put(|@Patch(/)) {
expect(content).toMatch(/Dto/);
}
});
});
});# Gerar relatório de cobertura
npm run test:cov
# Visualizar relatório HTML
open coverage/lcov-report/index.htmlExemplo de saída:
----------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
----------------------|---------|----------|---------|---------|
All files | 85.23 | 78.45 | 82.67 | 86.12 |
core/entities | 92.15 | 85.33 | 90.00 | 93.45 |
core/use-cases | 88.76 | 82.14 | 87.23 | 89.34 |
infra/database | 81.45 | 75.67 | 79.12 | 82.89 |
modules/auth | 90.23 | 86.45 | 91.34 | 91.67 |
modules/user | 87.34 | 80.23 | 85.67 | 88.45 |
----------------------|---------|----------|---------|---------|
# 1. Instalar CLI
npm install -g @railway/cli
# 2. Login (browser-based)
railway login
# 3. Inicializar projeto e fazer deploy
# O Railway fará o build e deploy automaticamente
# após a inicialização e push, ou via link com o GitHub.
# Para deploy manual do diretório atual:
railway upConsulte a documentação específica de cada provedor para deploy de aplicações Node.js com Docker.
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gestaoint_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run type check
run: npm run type-check
- name: Run tests
run: npm run test:cov
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gestaoint_test
REDIS_HOST: localhost
REDIS_PORT: 6379
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
build:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Build Docker image
run: docker build -t gestaoint-api:latest .
- name: Push to registry
run: |
echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
docker tag gestaoint-api:latest ${{ secrets.DOCKER_USERNAME }}/gestaoint-api:latest
docker push ${{ secrets.DOCKER_USERNAME }}/gestaoint-api:latest
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to production
run: |
# Adicione aqui os comandos de deploy para seu provedor
echo "Deploying to production..."A API é otimizada para alta performance:
- ⚡ Response Time: < 200ms (média)
- 🚀 Throughput: > 1000 req/s
- 💾 Memory Usage: < 512MB
- 🔄 CPU Usage: < 50%
// Fastify é ~2x mais rápido que Express
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: true }),
);// Cache de queries frequentes
@Injectable()
export class UserService {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
async findById(id: string): Promise<User> {
const cacheKey = `user:${id}`;
// Tentar buscar do cache
const cached = await this.cacheManager.get<User>(cacheKey);
if (cached) return cached;
// Buscar do banco
const user = await this.userRepository.findById(id);
// Salvar no cache (TTL: 5 minutos)
await this.cacheManager.set(cacheKey, user, 300);
return user;
}
}// Usar select específico ao invés de buscar tudo
const users = await prisma.users.findMany({
select: {
id: true,
email: true,
name: true,
// Não buscar password_hash desnecessariamente
},
where: { is_active: true },
take: 10,
});
// Usar includes com cuidado
const user = await prisma.users.findUnique({
where: { id },
include: {
company: {
select: {
id: true,
name: true,
// Não incluir todos os campos
},
},
},
});// Configuração do Prisma para connection pooling
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
// Pool de conexões
connection_limit = 10
pool_timeout = 20
}// Compressão de respostas HTTP
import compression from '@fastify/compress';
app.register(compression, {
encodings: ['gzip', 'deflate'],
threshold: 1024, // Comprimir apenas respostas > 1KB
});// Sempre usar paginação em listagens
@Get()
async findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
) {
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.prisma.users.findMany({ skip, take: limit }),
this.prisma.users.count(),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}// Prometheus metrics
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
@Module({
imports: [
PrometheusModule.register({
defaultMetrics: {
enabled: true,
},
}),
],
})
export class AppModule {}Proteção de headers HTTP.
import helmet from '@fastify/helmet';
app.register(helmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: [`'self'`],
styleSrc: [`'self'`, `'unsafe-inline'`],
imgSrc: [`'self'`, 'data:', 'validator.swagger.io'],
scriptSrc: [`'self'`, `https: 'unsafe-inline'`],
},
},
});Proteção contra abuso e DDoS.
@Module({
imports: [
ThrottlerModule.forRoot([
{
ttl: 60000, // 60 segundos
limit: 10, // 10 requisições
},
]),
],
})
export class AppModule {}Validação rigorosa com Zod.
import { z } from 'zod';
export const CreateUserSchema = z.object({
email: z.string().email('Email inválido'),
password: z
.string()
.min(8, 'Senha deve ter no mínimo 8 caracteres')
.regex(/[A-Z]/, 'Senha deve conter letra maiúscula')
.regex(/[a-z]/, 'Senha deve conter letra minúscula')
.regex(/[0-9]/, 'Senha deve conter número')
.regex(/[^A-Za-z0-9]/, 'Senha deve conter caractere especial'),
name: z.string().min(3, 'Nome deve ter no mínimo 3 caracteres'),
});Prisma ORM previne SQL injection automaticamente.
// ✅ SEGURO: Prisma usa prepared statements
const user = await prisma.users.findUnique({
where: { email: userInput },
});
// ❌ NUNCA FAÇA: Raw SQL com input do usuário
const user = await prisma.$queryRaw`
SELECT * FROM users WHERE email = ${userInput}
`; // VULNERÁVEL!app.enableCors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
});# ❌ NUNCA commite secrets no código
# ✅ Use variáveis de ambiente
JWT_SECRET=use-a-strong-random-secret-here
DATABASE_URL=postgresql://user:pass@localhost:5432/db// ❌ NUNCA logue informações sensíveis
logger.log(`User logged in: ${user.email}, password: ${user.password}`);
// ✅ Logue apenas informações necessárias
logger.log(`User logged in: ${user.id}`);- ✅ Senhas hasheadas com Argon2
- ✅ JWT com expiração configurada
- ✅ Rate limiting em todos os endpoints
- ✅ Helmet.js para headers seguros
- ✅ CORS configurado corretamente
- ✅ Input validation com Zod
- ✅ SQL injection protection (Prisma)
- ✅ XSS protection
- ✅ CSRF protection
- ✅ Secrets em variáveis de ambiente
- ✅ HTTPS em produção
- ✅ Logging sem informações sensíveis
Contribuições são bem-vindas! Siga estas diretrizes:
- Fork o projeto
- Crie uma branch para sua feature (
git checkout -b feature/AmazingFeature) - Commit suas mudanças (
git commit -m 'Add some AmazingFeature') - Push para a branch (
git push origin feature/AmazingFeature) - Abra um Pull Request
# Antes de commitar, execute:
# 1. Linter
npm run lint
# 2. Type check
npm run type-check
# 3. Testes
npm run test
# 4. Formatação
npm run formatSiga o padrão Conventional Commits:
feat: adiciona endpoint de relatórios financeiros
fix: corrige bug no cálculo de saldo
docs: atualiza documentação da API
test: adiciona testes para UserService
refactor: refatora estrutura de pastas
perf: otimiza query de listagem de produtos
Todos os PRs passam por code review. Certifique-se de:
- ✅ Código segue os padrões do projeto
- ✅ Testes estão passando
- ✅ Cobertura de testes mantida ou aumentada
- ✅ Documentação atualizada
- ✅ Sem conflitos com a branch main
- ✅ Clean Architecture + DDD
- ✅ Autenticação JWT
- ✅ CRUD de usuários, empresas, produtos
- ✅ Movimentações financeiras
- ✅ GraphQL API
- ✅ Testes (80%+ coverage)
- ✅ Docker support
- ✅ CI/CD pipeline
- 🚧 WebSockets para notificações em tempo real
- 🚧 Sistema de permissões granulares (RBAC)
- 🚧 Relatórios avançados com gráficos
- 🚧 Exportação de dados (PDF, Excel)
- 🚧 Integração com gateways de pagamento
- 🚧 Auditoria de ações (audit log)
- 📅 Multi-tenancy completo
- 📅 Microservices architecture
- 📅 Event sourcing
- 📅 Machine learning para insights
- 📅 Mobile app (React Native)
- 📅 Internacionalização (i18n)
- Integração com ERPs
- API pública para terceiros
- Marketplace de plugins
- Dashboard analytics avançado
- Automações com workflows
Este projeto está sob a licença UNLICENSED. Veja o arquivo LICENSE para mais detalhes.
João - @Joaoof
- NestJS - Framework incrível
- Prisma - ORM de próxima geração
- Fastify - Web framework rápido
- Comunidade open source
- 📧 Email: seu-email@example.com
- 🐛 Issues: GitHub Issues
- 💬 Discussions: GitHub Discussions
⭐ Se este projeto foi útil, considere dar uma estrela!
Made with ❤️ by João