Files
MeowSpool/backend/agent.md
T
2026-03-14 19:45:08 -03:00

467 lines
18 KiB
Markdown

# MeowSpool Backend — Agent Guide
## Visão Geral
Backend da aplicação MeowSpool escrito em **Rust**, utilizando **Axum** como framework HTTP e **SQLx** com **PostgreSQL** como banco de dados. A arquitetura segue o padrão **Hexagonal (Ports & Adapters)**, garantindo que o núcleo do domínio seja completamente isolado de detalhes de infraestrutura.
---
## Stack
| Componente | Biblioteca | Versão mínima |
| ---------------- | ------------------------------------- | ------------- |
| HTTP Framework | `axum` | 0.7 |
| Async Runtime | `tokio` (full) | 1.x |
| ORM / Query | `sqlx` (postgres, uuid, time, macros) | 0.8 |
| Autenticação JWT | `jsonwebtoken` | 9.x |
| Hash de senha | `argon2` | 0.5 |
| OAuth Google | `oauth2` | 4.x |
| UUID | `uuid` (v4, serde) | 1.x |
| Serialização | `serde`, `serde_json` | 1.x |
| Erros | `thiserror` | 1.x |
| Env vars | `dotenvy` | 0.15 |
| Logging | `tracing`, `tracing-subscriber` | 0.1 |
| Validação | `validator` | 0.18 |
| HTTP Client | `reqwest` (json, rustls-tls) | 0.12 |
| Geração QR Code | `qrcode` | 0.14 |
| Geração de imagem| `image` | 0.25 |
| Encode Base64 | `base64` | 0.22 |
| Geração PDF | `printpdf` | 0.7 |
---
## Estrutura de Pastas
```
backend/
├── Cargo.toml
├── Cargo.lock
├── .env.example
├── agent.md <- este arquivo
├── migrations/ <- SQL puro, gerenciado pelo SQLx CLI
│ ├── 20240101000001_create_users.sql
│ ├── 20240101000002_create_spool_presets.sql
│ └── 20240101000003_create_filaments.sql
└── src/
├── main.rs <- entry point: inicializa config, DB, router e servidor
├── config.rs <- struct Config lida de variáveis de ambiente
├── error.rs <- AppError unificado com IntoResponse
├── router.rs <- composição de todas as rotas Axum
├── domain/ <- NÚCLEO: entidades puras, sem dependências externas
│ ├── mod.rs
│ ├── user.rs <- struct User, enum AuthProvider
│ ├── filament.rs <- struct Filament, enum Material
│ └── spool_preset.rs <- struct SpoolPreset
├── ports/ <- INTERFACES: traits que o domínio exige
│ ├── mod.rs
│ ├── user_repository.rs <- trait UserRepository
│ ├── filament_repository.rs <- trait FilamentRepository
│ └── spool_preset_repository.rs <- trait SpoolPresetRepository
├── application/ <- CASOS DE USO: orquestram domínio + ports
│ ├── mod.rs
│ ├── auth_service.rs <- login, register, OAuth, refresh, logout
│ ├── filament_service.rs <- CRUD, cálculo de peso líquido, QR, SVG
│ └── spool_preset_service.rs <- CRUD presets (system read-only, user CRUD)
└── adapters/
├── inbound/ <- HTTP: recebe requisições, delega ao application
│ ├── mod.rs
│ ├── auth_handler.rs
│ ├── filament_handler.rs
│ ├── spool_preset_handler.rs
│ ├── user_handler.rs
│ └── middleware/
│ └── auth.rs <- extrator JWT que popula CurrentUser no estado
└── outbound/ <- INFRAESTRUTURA: implementa as traits de ports
├── mod.rs
├── postgres_user_repo.rs
├── postgres_filament_repo.rs
└── postgres_spool_preset_repo.rs
```
---
## Arquitetura Hexagonal — Regras de Dependência
```
adapters/inbound (HTTP)
|
v
application (services)
|
v
domain (entidades)
|
^
ports (traits)
|
^
adapters/outbound (PostgreSQL)
```
**Regra fundamental:** O `domain` e os `ports` **nunca** importam nada de `adapters` ou `application`. Dependências permitidas no domínio: `uuid`, `serde`, `time`. Qualquer violação é um bug arquitetural.
---
## Rotas da API
Todas as rotas são prefixadas com `/api/v1`.
### Auth — `/api/v1/auth`
| Método | Rota | Handler | Acesso |
| ------ | ------------------ | ------------------------- | ------------------------------- |
| POST | `/register` | `register_handler` | Público |
| POST | `/login` | `login_handler` | Público |
| POST | `/oauth/google` | `google_oauth_handler` | Público |
| POST | `/refresh` | `refresh_token_handler` | Público (requer refresh token) |
| POST | `/logout` | `logout_handler` | Autenticado |
| POST | `/forgot-password` | `forgot_password_handler` | Público |
| POST | `/verify-email` | `verify_email_handler` | Público |
| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset) |
### Users — `/api/v1/users`
| Método | Rota | Handler | Acesso |
| ------ | ----- | ------------------- | ----------- |
| GET | `/me` | `get_me_handler` | Autenticado |
| PUT | `/me` | `update_me_handler` | Autenticado |
### Filaments — `/api/v1/filaments`
| Método | Rota | Handler | Acesso |
| ------ | ---------------- | ------------------------- | ----------- |
| GET | `/` | `list_filaments_handler` | Autenticado |
| POST | `/` | `create_filament_handler` | Autenticado |
| GET | `/:id` | `get_filament_handler` | Autenticado |
| PUT | `/:id` | `update_filament_handler` | Autenticado |
| DELETE | `/:id` | `delete_filament_handler` | Autenticado |
| GET | `/:id/qrcode` | `get_qrcode_handler` | Autenticado |
| GET | `/:id/label.svg` | `export_label_handler` | Autenticado |
| GET | `/:id/label.pdf` | `export_label_pdf_handler`| Autenticado |
**Query params de listagem (`GET /filaments`):**
- `material` — filtra por tipo (PLA, ABS, PETG, TPU, ASA, PA, PC...)
- `brand` — filtra por marca
- `search` — busca em marca, modelo e notas
- `stock_level``low` (<=15%), `medium` (<=35%), `ok` (>35%)
- `sort``net_weight_asc`, `net_weight_desc`, `created_at_desc` (padrão)
- `page` / `per_page` — paginação (padrão: página 1, 20 itens)
### Spool Presets — `/api/v1/spool-presets`
| Método | Rota | Handler | Acesso |
| ------ | ------ | ----------------------- | ------------------------------------ |
| GET | `/` | `list_presets_handler` | Autenticado |
| POST | `/` | `create_preset_handler` | Autenticado |
| PUT | `/:id` | `update_preset_handler` | Autenticado (apenas presets do user) |
| DELETE | `/:id` | `delete_preset_handler` | Autenticado (apenas presets do user) |
### Dashboard — `/api/v1/dashboard`
| Método | Rota | Handler | Acesso |
| ------ | ---- | ------------------- | ----------- |
| GET | `/` | `dashboard_handler` | Autenticado |
**Resposta do dashboard:**
```json
{
"total_stock_kg": 14.2,
"low_stock_count": 3,
"by_material": [
{ "material": "PLA", "count": 4, "total_kg": 5.8 }
],
"low_stock_filaments": [
{ "id": "...", "model": "PETG", "net_weight_g": 120, "percentage": 12 }
],
"recent_filaments": [...]
}
```
---
## Padrão de Erros
Use `thiserror` em todos os módulos e um `AppError` central em `src/error.rs`:
```rust
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("not found")]
NotFound,
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("validation error: {0}")]
Validation(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("internal error")]
Internal(#[from] anyhow::Error),
}
```
`AppError` implementa `IntoResponse` do Axum, mapeando cada variante para o status HTTP correto e body JSON consistente:
```json
{ "error": "not found", "code": "NOT_FOUND" }
```
---
## Convenções de Código
### Nomenclatura
- **Structs de domínio:** `PascalCase``User`, `Filament`, `SpoolPreset`
- **Traits (ports):** `PascalCase` com sufixo `Repository``UserRepository`
- **Implementações concretas:** prefixo do banco — `PostgresUserRepository`
- **Serviços:** sufixo `Service``AuthService`, `FilamentService`
- **Handlers:** sufixo `_handler``login_handler`, `create_filament_handler`
- **DTOs de entrada:** sufixo `Request``CreateFilamentRequest`
- **DTOs de saída:** sufixo `Response``FilamentResponse`
### Estrutura de um Handler
Todo handler deve ser uma função async pequena. A lógica de negócio **nunca** fica no handler — ela fica no `application/*_service.rs`.
```rust
// adapters/inbound/filament_handler.rs
pub async fn create_filament_handler(
State(state): State<AppState>,
Extension(current_user): Extension<CurrentUser>,
Json(req): Json<CreateFilamentRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
let filament = state.filament_service.create(current_user.id, req).await?;
Ok((StatusCode::CREATED, Json(FilamentResponse::from(filament))))
}
```
### Estrutura de um Service
O service recebe repositórios via injeção de dependência (trait objects em Arc):
```rust
// application/filament_service.rs
pub struct FilamentService {
repo: Arc<dyn FilamentRepository>,
preset_repo: Arc<dyn SpoolPresetRepository>,
}
impl FilamentService {
pub async fn create(&self, user_id: Uuid, req: CreateFilamentRequest) -> Result<Filament, AppError> {
// 1. buscar preset para calcular net_weight
// 2. calcular net_weight_g = total_weight_g - spool_weight_g
// 3. construir entidade Filament
// 4. persistir via repo
// 5. retornar entidade
}
}
```
### Estrutura de um Repository (Port)
```rust
// ports/filament_repository.rs
#[async_trait::async_trait]
pub trait FilamentRepository: Send + Sync {
async fn find_by_id(&self, id: Uuid, user_id: Uuid) -> Result<Option<Filament>, AppError>;
async fn list(&self, user_id: Uuid, filter: FilamentFilter) -> Result<Vec<Filament>, AppError>;
async fn create(&self, filament: &Filament) -> Result<Filament, AppError>;
async fn update(&self, filament: &Filament) -> Result<Filament, AppError>;
async fn delete(&self, id: Uuid, user_id: Uuid) -> Result<(), AppError>;
}
```
---
## Migrations
Use o **SQLx CLI** para gerenciar migrations:
```bash
# instalar
cargo install sqlx-cli --no-default-features --features postgres
# criar nova migration
sqlx migrate add <nome_descritivo>
# aplicar
sqlx migrate run
# reverter
sqlx migrate revert
```
Arquivos ficam em `backend/migrations/`. Nomeie com timestamp e descrição clara.
---
## Variáveis de Ambiente
Copie `.env.example` para `.env` antes de rodar. Veja o arquivo `.env.example` para a lista completa.
---
## Como Adicionar um Novo Endpoint (Passo a Passo)
1. **Domain** — adicione ou modifique a entidade em `src/domain/`.
2. **Port** — adicione o método necessário no trait em `src/ports/`.
3. **Outbound Adapter** — implemente o método no repositório PostgreSQL em `src/adapters/outbound/`.
4. **Application Service** — adicione o caso de uso em `src/application/`, chamando o port.
5. **Request/Response DTOs** — defina structs com `serde` e `validator` no handler.
6. **Inbound Handler** — crie o handler em `src/adapters/inbound/`, delegando para o service.
7. **Router** — registre a rota em `src/router.rs`.
8. **Migration** — se necessário, crie um arquivo SQL em `migrations/`.
---
## Como Rodar Localmente
```bash
# na raiz do backend/
cp .env.example .env
# edite .env com suas credenciais locais
# subir PostgreSQL via Docker
docker run -d \
--name meowspool-db \
-e POSTGRES_USER=meowspool \
-e POSTGRES_PASSWORD=meowspool \
-e POSTGRES_DB=meowspool \
-p 5432:5432 \
postgres:16-alpine
# rodar migrations
sqlx migrate run
# rodar em modo watch (requer cargo-watch)
cargo watch -x run
```
---
## Sincronização Offline (Estratégia)
O backend implementa **last-write-wins com timestamp**:
- Toda entidade possui `updated_at` (timestamp UTC).
- O mobile envia o `updated_at` local no body do PUT.
- Se o `updated_at` do servidor for mais recente, retorna `409 Conflict` com a versão do servidor.
- Se o `updated_at` do cliente for mais recente (ou igual), o servidor aceita a atualização.
---
## Geração de QR Code e Etiqueta SVG
### QR Code (`GET /filaments/:id/qrcode`)
- Gera QR Code com deep link: `meowspool://filament/:id` (singular)
- Retorna PNG (`image/png`) por padrão, ou SVG com `?format=svg`
- Biblioteca: crate `qrcode`
### Etiqueta SVG (`GET /filaments/:id/label.svg`)
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional)
- Retorna SVG com os campos selecionados embutidos
- `Content-Type: image/svg+xml`
- `Content-Disposition: attachment; filename="meowspool-label-{id}.svg"`
### Etiqueta PDF (`GET /filaments/:id/label.pdf`)
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional)
- Retorna PDF de página única com dimensões exatas em mm — ideal para impressoras de etiqueta (Brother, Dymo)
- Gerado com `printpdf`: fundo escuro `#1E1B18`, barra de cor do filamento, texto com Helvetica builtin, QR Code embutido como PNG luma
- `Content-Type: application/pdf`
- `Content-Disposition: attachment; filename="meowspool-label-{id}.pdf"`
### Parâmetro `fields` (SVG e PDF)
Controla quais elementos aparecem na etiqueta. Valor: string com itens separados por vírgula.
| Valor | Elemento |
| --------------- | ------------------------------- |
| `color` | Barra de cor lateral |
| `name` | Nome do modelo |
| `material_brand`| Texto "Marca · Material" |
| `net_weight` | Peso líquido disponível |
| `print_temp` | Temperatura de impressão |
| `qrcode` | QR Code com deep link |
Se `fields` for omitido, todos os elementos são incluídos.
Implementado em `LabelFields` (`filament_service.rs`) — `LabelFields::from_str(Option<&str>)`.
---
## Segurança
- Senhas hasheadas com **Argon2id** (crate `argon2`).
- JWT assinado com **HS256** — nunca exponha o `JWT_SECRET`.
- Toda rota autenticada passa pelo middleware `auth.rs` que valida o token e popula `Extension<CurrentUser>`.
- Queries usam **bind parameters** do SQLx — nunca interpolação de string em SQL.
- `user_id` é sempre extraído do token JWT, nunca aceito como parâmetro de URL ou body.
- Presets do sistema (`is_system = true`) são protegidos no nível de serviço: edição ou deleção retorna `403 Forbidden`.
---
## Mudanças Recentes (14/03/2026)
### ✅ Exportação de Etiqueta PDF (`GET /filaments/:id/label.pdf`)
- **Dependência**: `printpdf = "0.7"` adicionada ao `Cargo.toml`
- **Service**: `FilamentService::generate_label_pdf` em `filament_service.rs`
- Layout: fundo `#1E1B18`, barra de cor (filament.color_hex), texto Helvetica builtin, QR Code PNG luma embutido
- Página com dimensões exatas em mm via `printpdf::Mm`
- Usa `Polygon` + `PaintMode::Fill` para retângulos (API do printpdf 0.7)
- QR Code embutido via `ImageXObject` com pixels luma brutos — sem depender do feature `image` do printpdf
- **Handler**: `export_label_pdf_handler` em `filament_handler.rs`
- **Rota**: `GET /api/v1/filaments/:id/label.pdf`
### ✅ Controle de Campos na Etiqueta (`fields` query param)
- **Struct**: `LabelFields` em `filament_service.rs` — parseia string CSV de campos ativos
- **Aplicado em**: `generate_label_svg` e `generate_label_pdf`
- **LabelQuery**: adicionado campo `fields: Option<String>` em `filament_handler.rs`
- Permite que o cliente selecione quais elementos aparecem na etiqueta (cor, nome, material, peso, temp, QR)
### ✅ Correção: PUT Spool Preset retornava FORBIDDEN
**Local:** `src/adapters/outbound/postgres_spool_preset_repo.rs` — método `update()`
**Problema:**
O SQL UPDATE não validava o `user_id` na cláusula WHERE. Qualquer usuário poderia tentar editar presets de outros usuários ou presets do sistema.
```rust
// ❌ ANTES (inseguro)
UPDATE spool_presets
SET name = $2, spool_weight_g = $3
WHERE id = $1 AND is_system = false
RETURNING ...
```
**Solução:**
Adicionado `AND user_id = $4` para validar propriedade antes de atualizar:
```rust
// ✅ DEPOIS (seguro)
UPDATE spool_presets
SET name = $2, spool_weight_g = $3
WHERE id = $1 AND is_system = false AND user_id = $4
RETURNING ...
```
Agora o repositório valida que o preset pertence ao usuário autenticado (extraído do JWT). Tentativas de editar presets de outro usuário ou do sistema recebem `404 Not Found` (sem vazar que o preset existe).