Files
MeowSpool/backend/agent.md
T

530 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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), `dpi` (opcional, padrão 96)
- Layout adaptativo: modo **mini** para `width_mm < 30` ou `height_mm < 20`
- `dpi` afeta `px_per_mm` e portanto as dimensões em pixels do SVG gerado
- `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), `dpi` (opcional, reservado)
- Layout adaptativo: modo **mini** para `width_mm < 30` ou `height_mm < 20` — fontes e posições proporcionais, garante que nenhum elemento saia da página
- Gerado com `printpdf`: fundo escuro `#1E1B18`, barra de cor do filamento, texto com Helvetica builtin, QR Code PNG luma embutido com `interpolate: false` (bordas nítidas em impressoras térmicas)
- `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>)`.
### Parâmetro `dpi` (SVG e PDF)
Controla o DPI alvo da impressora. Afeta o cálculo de pixels do SVG e garante QR nítido em impressoras térmicas.
| Impressora | `dpi` recomendado |
| --------------------- | ----------------- |
| Tela / padrão | 96 (omitir param) |
| Niimbot D11/D110 | 203 |
| Brother QL / Dymo | 300 |
### Layout adaptativo ("mini" vs padrão)
Etiquetas com `width_mm < 30` **ou** `height_mm < 20` ativam o modo **mini**, que:
- Usa fontes menores (proporcionais à altura física)
- Posiciona QR Code com margem de 1mm (vs 2mm no padrão)
- Estreita a barra de cor (~11% da largura vs ~5% + 6px no padrão)
- Encurta o rótulo de peso: `"320g"` em vez de `"320g disponível"`
- Separador de marca/material: espaço simples em vez de ` · ` (economiza espaço)
- No SVG, o separador de meta usa espaço em vez de ` · `
- Garante que nenhum texto extrapole a borda inferior da etiqueta
Exemplo para Niimbot 22×14mm:
```
GET /api/v1/filaments/:id/label.pdf?width_mm=22&height_mm=14&fields=color,name,qrcode&dpi=203
GET /api/v1/filaments/:id/label.svg?width_mm=22&height_mm=14&fields=color,name,qrcode&dpi=203
```
---
## 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)
### ✅ Suporte a Impressoras Pequenas — Niimbot e layout adaptativo
**Arquivos alterados**: `filament_handler.rs`, `filament_service.rs`
#### Novo query param `dpi`
Adicionado campo `dpi: Option<u32>` ao `LabelQuery` (handler). Aceito em `/label.svg` e `/label.pdf`. Valores recomendados:
| Impressora | `dpi` |
| ---------------- | ----- |
| Tela / padrão | omitir (96) |
| Niimbot D11/D110 | 203 |
| Brother / Dymo | 300 |
No SVG, `dpi` define `px_per_mm = dpi / 25.4` — o tamanho em pixels do arquivo corresponde à resolução física da impressora. No PDF, o `dpi` é ignorado (PDF já trabalha em mm), mas reservado para uso futuro.
#### Layout adaptativo — modo "mini"
Ativado quando `width_mm < 30` **ou** `height_mm < 20` (ex: Niimbot 22×14mm). Diferenças em relação ao modo padrão:
| Aspecto | Padrão | Mini |
| --- | --- | --- |
| Barra de cor | 3.5mm fixo | 11% da largura |
| QR size | 75% da altura | 80% da altura |
| Margem QR | 2mm | 1mm |
| Y do texto | `h - 8/14/20` (fixo, quebrava para h<20) | `h * 0.72/0.50/0.28` (proporcional) |
| Fontes | 79pt fixo | proporcionais à altura física |
| Rótulo peso | `"320g disponível"` | `"320g"` |
| Separador meta | ` · ` | ` ` (espaço) |
| `interpolate` no QR (PDF) | `true` | `false` (bordas nítidas em térmica) |
**Correção crítica**: no modo anterior, `Mm(h - 20.0)` gerava coordenada negativa para `h < 20mm` (Niimbot), colocando o texto fora da página.
---
### ✅ 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).