530 lines
20 KiB
Markdown
530 lines
20 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), `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 | 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<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).
|