# 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, Extension(current_user): Extension, Json(req): Json, ) -> Result { 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, preset_repo: Arc, } impl FilamentService { pub async fn create(&self, user_id: Uuid, req: CreateFilamentRequest) -> Result { // 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, AppError>; async fn list(&self, user_id: Uuid, filter: FilamentFilter) -> Result, AppError>; async fn create(&self, filament: &Filament) -> Result; async fn update(&self, filament: &Filament) -> Result; 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 # 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`. - 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` 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 | 7–9pt 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` 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).