feat: implement filament and spool preset services with CRUD operations

- Add `FilamentService` for managing filament inventory, including creation, retrieval, updating, and deletion of filaments.
- Introduce `SpoolPresetService` for handling spool presets, allowing users to create, update, and delete their custom presets.
- Create domain models for `Filament` and `SpoolPreset` with necessary fields and methods.
- Define repository interfaces for filament and spool preset persistence.
- Implement application configuration management from environment variables.
- Set up error handling with a centralized `AppError` type.
- Build the Axum router with public and protected routes for user authentication and resource management.
This commit is contained in:
2026-03-14 09:38:28 -03:00
parent c90d2f920b
commit abdc2fe8ce
67 changed files with 7832 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# --- PostgreSQL (usado pelo Docker Compose e pela connection string abaixo) ---
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=local_user
POSTGRES_DB=local_db
POSTGRES_PASSWORD=local_password
# --- App (lido por src/config.rs) ---
DATABASE_URL=postgres://local_user:local_password@localhost:5432/local_db
JWT_SECRET=dev-secret-change-in-production
JWT_EXPIRY_SECS=3600
JWT_REFRESH_EXPIRY_SECS=2592000
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
HOST=0.0.0.0
PORT=8080
APP_ENV=development
+18
View File
@@ -0,0 +1,18 @@
# Banco de dados
DATABASE_URL=postgresql://meowspool:meowspool@localhost:5432/meowspool
# JWT
JWT_SECRET=troque_por_um_secret_forte_em_producao_minimo_32_chars
JWT_EXPIRY_SECS=3600
JWT_REFRESH_EXPIRY_SECS=2592000
# OAuth Google
GOOGLE_CLIENT_ID=seu_google_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=seu_google_client_secret
# Servidor
HOST=0.0.0.0
PORT=8080
# Ambiente (development | production)
APP_ENV=development
+1
View File
@@ -0,0 +1 @@
target/
+4308
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
[package]
name = "meowspool-backend"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "meowspool"
path = "src/main.rs"
[dependencies]
# HTTP Framework
axum = { version = "0.7", features = ["macros"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
# Async Runtime
tokio = { version = "1", features = ["full"] }
# Database
sqlx = { version = "0.8", features = [
"runtime-tokio-rustls",
"postgres",
"uuid",
"time",
"macros",
] }
# Auth
jsonwebtoken = "9"
argon2 = "0.5"
oauth2 = "4"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# UUID
uuid = { version = "1", features = ["v4", "serde"] }
# Time
time = { version = "0.3", features = ["serde", "serde-well-known"] }
# Errors
thiserror = "1"
anyhow = "1"
# Validation
validator = { version = "0.18", features = ["derive"] }
# Env vars
dotenvy = "0.15"
# Logging / Tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# HTTP Client (OAuth, Google APIs)
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
# QR Code generation
qrcode = "0.14"
image = "0.25"
base64 = "0.22"
# Async trait
async-trait = "0.1"
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
axum-test = "15"
+383
View File
@@ -0,0 +1,383 @@
# 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 |
---
## 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 |
**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://filaments/:id`
- 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)
- Retorna SVG com: cor visual, modelo, material, marca, peso líquido e QR Code embutido
- `Content-Type: image/svg+xml`
- `Content-Disposition: attachment; filename="meowspool-label-{id}.svg"`
---
## 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`.
@@ -0,0 +1,9 @@
# Secrets
.env*
# Dependencies
node_modules
# OS files
.DS_Store
Thumbs.db
@@ -0,0 +1,20 @@
info:
name: Forgot Password
type: http
seq: 5
http:
method: POST
url: http://0.0.0.0:8080/api/v1/auth/forgot-password
body:
type: json
data: |-
{
"email": "felipe@felipecncloud.com"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,21 @@
info:
name: Login
type: http
seq: 2
http:
method: POST
url: http://0.0.0.0:8080/api/v1/auth/login
body:
type: json
data: |-
{
"email": "felipe@felipecncloud.com",
"password": "12345678"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,24 @@
info:
name: Logout
type: http
seq: 2
http:
method: POST
url: http://0.0.0.0:8080/api/v1/auth/logout
body:
type: json
data: |-
{
"email": "felipe@felipecncloud.com",
"password": "l11f06c10"
}
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,20 @@
info:
name: OAuth Google
type: http
seq: 8
http:
method: POST
url: http://0.0.0.0:8080/api/v1/auth/oauth/google
body:
type: json
data: |-
{
"id_token": "{{google_id_token}}"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,20 @@
info:
name: Refresh Token
type: http
seq: 4
http:
method: POST
url: http://0.0.0.0:8080/api/v1/auth/refresh
body:
type: json
data: |-
{
"refresh_token": "{{refresh_token}}"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,21 @@
info:
name: Register
type: http
seq: 1
http:
method: POST
url: http://0.0.0.0:8080/api/v1/auth/register
body:
type: json
data: |-
{
"email": "felipe@felipecncloud.com",
"password": "12345678"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,21 @@
info:
name: Reset Password
type: http
seq: 7
http:
method: POST
url: http://0.0.0.0:8080/api/v1/auth/reset-password
body:
type: json
data: |-
{
"token": "{{reset_token}}",
"new_password": "novaSenha123"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,20 @@
info:
name: Verify Email
type: http
seq: 6
http:
method: POST
url: http://0.0.0.0:8080/api/v1/auth/verify-email
body:
type: json
data: |-
{
"token": "{{email_verify_token}}"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,6 @@
info:
name: Auth
type: folder
seq: 2
request: {}
@@ -0,0 +1,17 @@
info:
name: Dashboard
type: http
seq: 1
http:
method: GET
url: http://0.0.0.0:8080/api/v1/dashboard
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,32 @@
info:
name: Create Filament
type: http
seq: 2
http:
method: POST
url: http://0.0.0.0:8080/api/v1/filaments
body:
type: json
data: |-
{
"material": "PLA",
"brand": "Bambu",
"model": "PLA Basic",
"color_hex": "#FF5733",
"spool_preset_id": "{{preset_id}}",
"total_weight_g": 1250,
"temp_hotend_c": 220,
"temp_bed_c": 60,
"flow_factor_pct": 1.0,
"notes": "Filamento de teste"
}
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,17 @@
info:
name: Delete Filament
type: http
seq: 5
http:
method: DELETE
url: http://0.0.0.0:8080/api/v1/filaments/{{filament_id}}
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,24 @@
info:
name: Export Label SVG
type: http
seq: 7
http:
method: GET
url: http://0.0.0.0:8080/api/v1/filaments/{{filament_id}}/label.svg
params:
- name: width_mm
value: "80"
enabled: true
- name: height_mm
value: "30"
enabled: true
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,17 @@
info:
name: Get Filament
type: http
seq: 3
http:
method: GET
url: http://0.0.0.0:8080/api/v1/filaments/{{filament_id}}
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,22 @@
info:
name: Get QR Code
type: http
seq: 6
http:
method: GET
url: http://0.0.0.0:8080/api/v1/filaments/{{filament_id}}/qrcode
auth:
type: bearer
token: "{{access_token}}"
runtime:
variables:
- name: filament_id
value: e1edc611-f020-4234-94e5-db5e1393dbf2
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,36 @@
info:
name: List Filaments
type: http
seq: 1
http:
method: GET
url: http://0.0.0.0:8080/api/v1/filaments
params:
- name: page
value: "1"
enabled: true
- name: per_page
value: "20"
enabled: true
- name: material
value: PLA
enabled: false
- name: brand
value: Bambu
enabled: false
- name: search
value: ""
enabled: false
- name: stock_level
value: low
enabled: false
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,32 @@
info:
name: Update Filament
type: http
seq: 4
http:
method: PUT
url: http://0.0.0.0:8080/api/v1/filaments/{{filament_id}}
body:
type: json
data: |-
{
"material": "PETG",
"brand": "Bambu",
"model": "PETG Basic",
"color_hex": "#33A1FF",
"spool_preset_id": "{{preset_id}}",
"total_weight_g": 1000,
"temp_hotend_c": 240,
"temp_bed_c": 70,
"flow_factor_pct": 1.0,
"notes": "Atualizado"
}
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,6 @@
info:
name: Filaments
type: folder
seq: 3
request: {}
@@ -0,0 +1,24 @@
info:
name: Create Spool Preset
type: http
seq: 2
http:
method: POST
url: http://0.0.0.0:8080/api/v1/spool-presets
body:
type: json
data: |-
{
"name": "Carretel Customizado 300g",
"spool_weight_g": 300
}
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,17 @@
info:
name: Delete Spool Preset
type: http
seq: 4
http:
method: DELETE
url: http://0.0.0.0:8080/api/v1/spool-presets/{{preset_id}}
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,17 @@
info:
name: List Spool Presets
type: http
seq: 1
http:
method: GET
url: http://0.0.0.0:8080/api/v1/spool-presets
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,24 @@
info:
name: Update Spool Preset
type: http
seq: 3
http:
method: PUT
url: http://0.0.0.0:8080/api/v1/spool-presets/{{preset_id}}
body:
type: json
data: |-
{
"name": "Carretel Customizado 350g",
"spool_weight_g": 350
}
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,6 @@
info:
name: SpoolPresets
type: folder
seq: 4
request: {}
@@ -0,0 +1,17 @@
info:
name: Get Me
type: http
seq: 1
http:
method: GET
url: http://0.0.0.0:8080/api/v1/users/me
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,23 @@
info:
name: Update Me
type: http
seq: 2
http:
method: PUT
url: http://0.0.0.0:8080/api/v1/users/me
body:
type: json
data: |-
{
"email": "felipe@felipecncloud.com"
}
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,6 @@
info:
name: Users
type: folder
seq: 5
request: {}
@@ -0,0 +1,10 @@
name: route
variables:
- secret: true
name: access_token
- secret: true
name: refresh_token
- name: preset_id
value: 0ffa808d-af09-4bc6-9a40-43df3a4c6dd0
- name: filament_id
value: e1edc611-f020-4234-94e5-db5e1393dbf2
@@ -0,0 +1,10 @@
opencollection: 1.0.0
info:
name: MewoSpool
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git
+17
View File
@@ -0,0 +1,17 @@
services:
database:
image: "postgres:16.0-alpine3.18"
env_file:
- ../.env.development
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:
@@ -0,0 +1,16 @@
-- Migration: 001 - Criar tabela de usuários
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT,
google_id TEXT UNIQUE,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_google_id ON users (google_id) WHERE google_id IS NOT NULL;
@@ -0,0 +1,30 @@
-- Migration: 002 - Criar tabela de presets de carretel e inserir presets do sistema
CREATE TABLE spool_presets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
spool_weight_g INTEGER NOT NULL CHECK (spool_weight_g > 0),
is_system BOOLEAN NOT NULL DEFAULT FALSE,
user_id UUID REFERENCES users (id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Presets do sistema não têm user_id; presets customizados têm
CONSTRAINT chk_preset_ownership CHECK (
(is_system = TRUE AND user_id IS NULL) OR
(is_system = FALSE AND user_id IS NOT NULL)
)
);
CREATE INDEX idx_spool_presets_user_id ON spool_presets (user_id) WHERE user_id IS NOT NULL;
CREATE INDEX idx_spool_presets_is_system ON spool_presets (is_system);
-- Presets built-in do sistema (somente leitura para usuários)
INSERT INTO spool_presets (name, spool_weight_g, is_system) VALUES
('Bambu Lab (Plástico)', 250, TRUE),
('Elegoo (Papelão)', 200, TRUE),
('Creality (Plástico)', 230, TRUE),
('Prusament (Plástico)', 201, TRUE),
('Sunlu (Papelão)', 200, TRUE),
('Polymaker (Plástico)', 220, TRUE),
('Genérico Papelão 1kg', 200, TRUE),
('Genérico Plástico 1kg', 250, TRUE);
@@ -0,0 +1,26 @@
-- Migration: 003 - Criar tabela de filamentos
CREATE TABLE filaments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
material TEXT NOT NULL,
brand TEXT NOT NULL,
model TEXT,
color_hex TEXT NOT NULL,
spool_preset_id UUID NOT NULL REFERENCES spool_presets (id),
total_weight_g INTEGER NOT NULL CHECK (total_weight_g > 0),
net_weight_g INTEGER NOT NULL CHECK (net_weight_g >= 0),
temp_hotend_c INTEGER CHECK (temp_hotend_c > 0),
temp_bed_c INTEGER CHECK (temp_bed_c >= 0),
flow_factor_pct DOUBLE PRECISION CHECK (flow_factor_pct > 0),
notes TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_filaments_user_id ON filaments (user_id);
CREATE INDEX idx_filaments_material ON filaments (user_id, material);
CREATE INDEX idx_filaments_brand ON filaments (user_id, brand);
CREATE INDEX idx_filaments_updated_at ON filaments (user_id, updated_at DESC);
-- Índice para suporte a alertas de estoque baixo
CREATE INDEX idx_filaments_net_weight ON filaments (user_id, net_weight_g);
@@ -0,0 +1,190 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::{error::AppError, router::AppState};
// -------------------------------------------------------------------------
// Request / Response DTOs
// -------------------------------------------------------------------------
#[derive(Debug, Deserialize, Validate)]
pub struct RegisterRequest {
#[validate(email(message = "invalid email format"))]
pub email: String,
#[validate(length(min = 8, message = "password must be at least 8 characters"))]
pub password: String,
}
#[derive(Debug, Deserialize, Validate)]
pub struct LoginRequest {
#[validate(email)]
pub email: String,
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct GoogleOAuthRequest {
/// ID token retornado pelo SDK do Google no cliente
pub id_token: String,
}
#[derive(Debug, Deserialize)]
pub struct RefreshTokenRequest {
pub refresh_token: String,
}
#[derive(Debug, Deserialize, Validate)]
pub struct ForgotPasswordRequest {
#[validate(email)]
pub email: String,
}
#[derive(Debug, Deserialize)]
pub struct VerifyEmailRequest {
pub token: String,
}
#[derive(Debug, Deserialize, Validate)]
pub struct ResetPasswordRequest {
pub token: String,
#[validate(length(min = 8))]
pub new_password: String,
}
#[derive(Debug, Serialize)]
pub struct AuthResponse {
pub access_token: String,
pub refresh_token: String,
pub token_type: &'static str,
}
// -------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------
pub async fn register_handler(
State(state): State<AppState>,
Json(req): Json<RegisterRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
let tokens = state.auth_service.register(req.email, req.password).await?;
Ok((
StatusCode::CREATED,
Json(AuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_type: "Bearer",
}),
))
}
pub async fn login_handler(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
let tokens = state.auth_service.login(req.email, req.password).await?;
Ok(Json(AuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_type: "Bearer",
}))
}
pub async fn google_oauth_handler(
State(state): State<AppState>,
Json(req): Json<GoogleOAuthRequest>,
) -> Result<impl IntoResponse, AppError> {
// Verificar id_token com a API do Google e extrair sub + email
let (google_id, email) = verify_google_id_token(&req.id_token, &state.config.google_client_id).await?;
let tokens = state.auth_service.google_oauth(google_id, email).await?;
Ok(Json(AuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_type: "Bearer",
}))
}
pub async fn refresh_token_handler(
State(state): State<AppState>,
Json(req): Json<RefreshTokenRequest>,
) -> Result<impl IntoResponse, AppError> {
let tokens = state.auth_service.refresh_token(&req.refresh_token).await?;
Ok(Json(AuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_type: "Bearer",
}))
}
pub async fn logout_handler() -> impl IntoResponse {
// JWT é stateless; o cliente descarta os tokens.
// Implementação futura: blocklist de refresh tokens via Redis.
StatusCode::NO_CONTENT
}
pub async fn forgot_password_handler(
Json(req): Json<ForgotPasswordRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
// TODO: gerar token de reset e enviar email
// Retornamos sempre 200 para não vazar informação sobre emails cadastrados
Ok(StatusCode::OK)
}
pub async fn verify_email_handler(
Json(_req): Json<VerifyEmailRequest>,
) -> Result<impl IntoResponse, AppError> {
// TODO: validar token e marcar email_verified = true
Ok(StatusCode::OK)
}
pub async fn reset_password_handler(
Json(req): Json<ResetPasswordRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
// TODO: validar token de reset e atualizar senha
Ok(StatusCode::OK)
}
// -------------------------------------------------------------------------
// Helper: verificação de Google ID Token
// -------------------------------------------------------------------------
/// Verifica o id_token do Google chamando o endpoint tokeninfo e retorna (sub, email).
async fn verify_google_id_token(
id_token: &str,
_client_id: &str,
) -> Result<(String, String), AppError> {
let url = format!(
"https://oauth2.googleapis.com/tokeninfo?id_token={id_token}"
);
let resp = reqwest::Client::new()
.get(&url)
.send()
.await
.map_err(|e| AppError::Internal(e.into()))?;
if !resp.status().is_success() {
return Err(AppError::Unauthorized);
}
let payload: serde_json::Value = resp
.json()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let sub = payload["sub"]
.as_str()
.ok_or(AppError::Unauthorized)?
.to_string();
let email = payload["email"]
.as_str()
.ok_or(AppError::Unauthorized)?
.to_string();
Ok((sub, email))
}
@@ -0,0 +1,311 @@
use axum::{
extract::{Path, Query, State},
http::{header, StatusCode},
response::IntoResponse,
Extension, Json,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use validator::Validate;
use crate::{
adapters::inbound::middleware::auth::CurrentUser,
application::filament_service::{
CreateFilamentInput, DashboardStats, MaterialSummary, UpdateFilamentInput,
},
domain::filament::Filament,
error::AppError,
ports::FilamentFilter,
router::AppState,
};
// -------------------------------------------------------------------------
// Query params
// -------------------------------------------------------------------------
#[derive(Debug, Deserialize, Default)]
pub struct ListFilamentsQuery {
pub material: Option<String>,
pub brand: Option<String>,
pub search: Option<String>,
pub stock_level: Option<String>,
pub sort: Option<String>,
#[serde(default = "default_page")]
pub page: u32,
#[serde(default = "default_per_page")]
pub per_page: u32,
}
fn default_page() -> u32 { 1 }
fn default_per_page() -> u32 { 20 }
#[derive(Debug, Deserialize)]
pub struct LabelQuery {
#[serde(default = "default_width_mm")]
pub width_mm: u32,
#[serde(default = "default_height_mm")]
pub height_mm: u32,
}
fn default_width_mm() -> u32 { 50 }
fn default_height_mm() -> u32 { 30 }
// -------------------------------------------------------------------------
// Request / Response DTOs
// -------------------------------------------------------------------------
#[derive(Debug, Deserialize, Validate)]
pub struct CreateFilamentRequest {
#[validate(length(min = 1, max = 20))]
pub material: String,
#[validate(length(min = 1, max = 100))]
pub brand: String,
#[validate(length(max = 100))]
pub model: Option<String>,
#[validate(length(min = 4, max = 7))]
pub color_hex: String,
pub spool_preset_id: Uuid,
#[validate(range(min = 1))]
pub total_weight_g: i32,
pub temp_hotend_c: Option<i32>,
pub temp_bed_c: Option<i32>,
pub flow_factor_pct: Option<f64>,
#[validate(length(max = 1000))]
pub notes: Option<String>,
}
#[derive(Debug, Deserialize, Validate)]
pub struct UpdateFilamentRequest {
#[validate(length(min = 1, max = 20))]
pub material: String,
#[validate(length(min = 1, max = 100))]
pub brand: String,
#[validate(length(max = 100))]
pub model: Option<String>,
#[validate(length(min = 4, max = 7))]
pub color_hex: String,
pub spool_preset_id: Uuid,
#[validate(range(min = 1))]
pub total_weight_g: i32,
pub temp_hotend_c: Option<i32>,
pub temp_bed_c: Option<i32>,
pub flow_factor_pct: Option<f64>,
#[validate(length(max = 1000))]
pub notes: Option<String>,
pub client_updated_at: Option<OffsetDateTime>,
}
#[derive(Debug, Serialize)]
pub struct FilamentResponse {
pub id: Uuid,
pub user_id: Uuid,
pub material: String,
pub brand: String,
pub model: Option<String>,
pub color_hex: String,
pub spool_preset_id: Uuid,
pub total_weight_g: i32,
pub net_weight_g: i32,
pub stock_percentage: u8,
pub temp_hotend_c: Option<i32>,
pub temp_bed_c: Option<i32>,
pub flow_factor_pct: Option<f64>,
pub notes: Option<String>,
pub updated_at: OffsetDateTime,
pub created_at: OffsetDateTime,
}
impl From<Filament> for FilamentResponse {
fn from(f: Filament) -> Self {
let stock_percentage = f.stock_percentage();
Self {
id: f.id,
user_id: f.user_id,
material: f.material.to_string(),
brand: f.brand,
model: f.model,
color_hex: f.color_hex,
spool_preset_id: f.spool_preset_id,
total_weight_g: f.total_weight_g,
net_weight_g: f.net_weight_g,
stock_percentage,
temp_hotend_c: f.temp_hotend_c,
temp_bed_c: f.temp_bed_c,
flow_factor_pct: f.flow_factor_pct,
notes: f.notes,
updated_at: f.updated_at,
created_at: f.created_at,
}
}
}
#[derive(Debug, Serialize)]
pub struct DashboardResponse {
pub total_stock_kg: f64,
pub low_stock_count: i64,
pub by_material: Vec<MaterialSummaryResponse>,
pub low_stock_filaments: Vec<FilamentResponse>,
pub recent_filaments: Vec<FilamentResponse>,
}
#[derive(Debug, Serialize)]
pub struct MaterialSummaryResponse {
pub material: String,
pub count: i64,
pub total_kg: f64,
}
impl From<MaterialSummary> for MaterialSummaryResponse {
fn from(m: MaterialSummary) -> Self {
Self {
material: m.material,
count: m.count,
total_kg: m.total_kg,
}
}
}
impl From<DashboardStats> for DashboardResponse {
fn from(s: DashboardStats) -> Self {
Self {
total_stock_kg: s.total_stock_kg,
low_stock_count: s.low_stock_count,
by_material: s.by_material.into_iter().map(Into::into).collect(),
low_stock_filaments: s.low_stock_filaments.into_iter().map(Into::into).collect(),
recent_filaments: s.recent_filaments.into_iter().map(Into::into).collect(),
}
}
}
// -------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------
pub async fn dashboard_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
) -> Result<impl IntoResponse, AppError> {
let stats = state.filament_service.dashboard(user.id).await?;
Ok(Json(DashboardResponse::from(stats)))
}
pub async fn list_filaments_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Query(query): Query<ListFilamentsQuery>,
) -> Result<impl IntoResponse, AppError> {
let filter = FilamentFilter {
material: query.material,
brand: query.brand,
search: query.search,
stock_level: query.stock_level,
sort: None, // TODO: parse sort string para SortOrder
page: query.page,
per_page: query.per_page,
};
let filaments = state.filament_service.list(user.id, filter).await?;
let response: Vec<FilamentResponse> = filaments.into_iter().map(Into::into).collect();
Ok(Json(response))
}
pub async fn create_filament_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Json(req): Json<CreateFilamentRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
let input = CreateFilamentInput {
material: req.material,
brand: req.brand,
model: req.model,
color_hex: req.color_hex,
spool_preset_id: req.spool_preset_id,
total_weight_g: req.total_weight_g,
temp_hotend_c: req.temp_hotend_c,
temp_bed_c: req.temp_bed_c,
flow_factor_pct: req.flow_factor_pct,
notes: req.notes,
};
let filament = state.filament_service.create(user.id, input).await?;
Ok((StatusCode::CREATED, Json(FilamentResponse::from(filament))))
}
pub async fn get_filament_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let filament = state.filament_service.get(id, user.id).await?;
Ok(Json(FilamentResponse::from(filament)))
}
pub async fn update_filament_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateFilamentRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
let input = UpdateFilamentInput {
material: req.material,
brand: req.brand,
model: req.model,
color_hex: req.color_hex,
spool_preset_id: req.spool_preset_id,
total_weight_g: req.total_weight_g,
temp_hotend_c: req.temp_hotend_c,
temp_bed_c: req.temp_bed_c,
flow_factor_pct: req.flow_factor_pct,
notes: req.notes,
client_updated_at: req.client_updated_at,
};
let filament = state.filament_service.update(id, user.id, input).await?;
Ok(Json(FilamentResponse::from(filament)))
}
pub async fn delete_filament_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
state.filament_service.delete(id, user.id).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn get_qrcode_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let png = state.filament_service.generate_qrcode_png(id, user.id).await?;
Ok((
[(header::CONTENT_TYPE, "image/png")],
png,
))
}
pub async fn export_label_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<Uuid>,
Query(query): Query<LabelQuery>,
) -> Result<impl IntoResponse, AppError> {
let svg = state
.filament_service
.generate_label_svg(id, user.id, query.width_mm, query.height_mm)
.await?;
let filename = format!("meowspool-label-{id}.svg");
Ok((
[
(header::CONTENT_TYPE, "image/svg+xml".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
),
],
svg,
))
}
@@ -0,0 +1,47 @@
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
};
use uuid::Uuid;
use crate::{error::AppError, router::AppState};
/// Informações do usuário autenticado, injetadas como Extension nos handlers.
#[derive(Debug, Clone)]
pub struct CurrentUser {
pub id: Uuid,
pub email: String,
}
/// Middleware de autenticação JWT.
/// Extrai o Bearer token do header Authorization, valida via AuthService
/// e injeta `CurrentUser` como Extension para uso nos handlers.
pub async fn auth_middleware(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, AppError> {
let token = extract_bearer_token(&req)?;
let claims = state.auth_service.validate_access_token(token)?;
let current_user = CurrentUser {
id: claims.sub,
email: claims.email,
};
req.extensions_mut().insert(current_user);
Ok(next.run(req).await)
}
fn extract_bearer_token(req: &Request) -> Result<&str, AppError> {
let header = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
header
.strip_prefix("Bearer ")
.ok_or(AppError::Unauthorized)
}
@@ -0,0 +1 @@
pub mod auth;
+5
View File
@@ -0,0 +1,5 @@
pub mod auth_handler;
pub mod filament_handler;
pub mod middleware;
pub mod spool_preset_handler;
pub mod user_handler;
@@ -0,0 +1,106 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Extension, Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use validator::Validate;
use crate::{
adapters::inbound::middleware::auth::CurrentUser,
domain::spool_preset::SpoolPreset,
error::AppError,
router::AppState,
};
// -------------------------------------------------------------------------
// Request / Response DTOs
// -------------------------------------------------------------------------
#[derive(Debug, Deserialize, Validate)]
pub struct CreatePresetRequest {
#[validate(length(min = 1, max = 100))]
pub name: String,
#[validate(range(min = 1, max = 2000))]
pub spool_weight_g: i32,
}
#[derive(Debug, Deserialize, Validate)]
pub struct UpdatePresetRequest {
#[validate(length(min = 1, max = 100))]
pub name: String,
#[validate(range(min = 1, max = 2000))]
pub spool_weight_g: i32,
}
#[derive(Debug, Serialize)]
pub struct SpoolPresetResponse {
pub id: Uuid,
pub name: String,
pub spool_weight_g: i32,
pub is_system: bool,
pub user_id: Option<Uuid>,
}
impl From<SpoolPreset> for SpoolPresetResponse {
fn from(p: SpoolPreset) -> Self {
Self {
id: p.id,
name: p.name,
spool_weight_g: p.spool_weight_g,
is_system: p.is_system,
user_id: p.user_id,
}
}
}
// -------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------
pub async fn list_presets_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
) -> Result<impl IntoResponse, AppError> {
let presets = state.spool_preset_service.list_for_user(user.id).await?;
let response: Vec<SpoolPresetResponse> = presets.into_iter().map(Into::into).collect();
Ok(Json(response))
}
pub async fn create_preset_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Json(req): Json<CreatePresetRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
let preset = state
.spool_preset_service
.create(user.id, req.name, req.spool_weight_g)
.await?;
Ok((StatusCode::CREATED, Json(SpoolPresetResponse::from(preset))))
}
pub async fn update_preset_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<Uuid>,
Json(req): Json<UpdatePresetRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
let preset = state
.spool_preset_service
.update(id, user.id, req.name, req.spool_weight_g)
.await?;
Ok(Json(SpoolPresetResponse::from(preset)))
}
pub async fn delete_preset_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
state.spool_preset_service.delete(id, user.id).await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -0,0 +1,72 @@
use axum::{extract::State, response::IntoResponse, Extension, Json};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use validator::Validate;
use crate::{
adapters::inbound::middleware::auth::CurrentUser,
domain::user::User,
error::AppError,
router::AppState,
};
// -------------------------------------------------------------------------
// Request / Response DTOs
// -------------------------------------------------------------------------
#[derive(Debug, Deserialize, Validate)]
pub struct UpdateMeRequest {
#[validate(email)]
pub email: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub email_verified: bool,
pub auth_provider: String,
pub created_at: OffsetDateTime,
}
impl From<User> for UserResponse {
fn from(u: User) -> Self {
let auth_provider = format!("{:?}", u.auth_provider()).to_lowercase();
Self {
id: u.id,
email: u.email,
email_verified: u.email_verified,
auth_provider,
created_at: u.created_at,
}
}
}
// -------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------
pub async fn get_me_handler(
State(state): State<AppState>,
Extension(current_user): Extension<CurrentUser>,
) -> Result<impl IntoResponse, AppError> {
let user = state
.auth_service
.user_repo_ref()
.find_by_id(current_user.id)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(UserResponse::from(user)))
}
pub async fn update_me_handler(
State(_state): State<AppState>,
Extension(_current_user): Extension<CurrentUser>,
Json(req): Json<UpdateMeRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
// TODO: implementar atualização de perfil
Ok(axum::http::StatusCode::OK)
}
+2
View File
@@ -0,0 +1,2 @@
pub mod inbound;
pub mod outbound;
+7
View File
@@ -0,0 +1,7 @@
pub mod postgres_filament_repo;
pub mod postgres_spool_preset_repo;
pub mod postgres_user_repo;
pub use postgres_filament_repo::PostgresFilamentRepository;
pub use postgres_spool_preset_repo::PostgresSpoolPresetRepository;
pub use postgres_user_repo::PostgresUserRepository;
@@ -0,0 +1,215 @@
use async_trait::async_trait;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use crate::{
domain::filament::{Filament, Material},
error::AppError,
ports::{FilamentFilter, FilamentRepository},
};
pub struct PostgresFilamentRepository {
db: Arc<PgPool>,
}
impl PostgresFilamentRepository {
pub fn new(db: Arc<PgPool>) -> Self {
Self { db }
}
}
#[async_trait]
impl FilamentRepository for PostgresFilamentRepository {
async fn find_by_id(&self, id: Uuid, user_id: Uuid) -> Result<Option<Filament>, AppError> {
let row = sqlx::query_as::<_, FilamentRow>(
r#"SELECT id, user_id, material, brand, model, color_hex, spool_preset_id,
total_weight_g, net_weight_g, temp_hotend_c, temp_bed_c,
flow_factor_pct, notes, updated_at, created_at
FROM filaments
WHERE id = $1 AND user_id = $2"#,
)
.bind(id)
.bind(user_id)
.fetch_optional(self.db.as_ref())
.await?;
Ok(row.map(Filament::from))
}
async fn list(&self, user_id: Uuid, filter: FilamentFilter) -> Result<Vec<Filament>, AppError> {
let rows = sqlx::query_as::<_, FilamentRow>(
r#"SELECT id, user_id, material, brand, model, color_hex, spool_preset_id,
total_weight_g, net_weight_g, temp_hotend_c, temp_bed_c,
flow_factor_pct, notes, updated_at, created_at
FROM filaments
WHERE user_id = $1
AND ($2::text IS NULL OR material ILIKE $2)
AND ($3::text IS NULL OR brand ILIKE '%' || $3 || '%')
AND ($4::text IS NULL OR (
brand ILIKE '%' || $4 || '%'
OR model ILIKE '%' || $4 || '%'
OR notes ILIKE '%' || $4 || '%'
))
AND ($5::text IS NULL OR (
($5 = 'low' AND net_weight_g::float / 1000.0 * 100 <= 15) OR
($5 = 'medium' AND net_weight_g::float / 1000.0 * 100 <= 35) OR
($5 = 'ok' AND net_weight_g::float / 1000.0 * 100 > 35)
))
ORDER BY created_at DESC
LIMIT $6 OFFSET $7"#,
)
.bind(user_id)
.bind(&filter.material)
.bind(&filter.brand)
.bind(&filter.search)
.bind(&filter.stock_level)
.bind(filter.per_page as i64)
.bind(((filter.page.saturating_sub(1)) * filter.per_page) as i64)
.fetch_all(self.db.as_ref())
.await?;
Ok(rows.into_iter().map(Filament::from).collect())
}
async fn count(&self, user_id: Uuid, filter: &FilamentFilter) -> Result<i64, AppError> {
let row: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM filaments
WHERE user_id = $1
AND ($2::text IS NULL OR material ILIKE $2)
AND ($3::text IS NULL OR brand ILIKE '%' || $3 || '%')
AND ($4::text IS NULL OR (
brand ILIKE '%' || $4 || '%'
OR model ILIKE '%' || $4 || '%'
OR notes ILIKE '%' || $4 || '%'
))"#,
)
.bind(user_id)
.bind(&filter.material)
.bind(&filter.brand)
.bind(&filter.search)
.fetch_one(self.db.as_ref())
.await?;
Ok(row.0)
}
async fn create(&self, filament: &Filament) -> Result<Filament, AppError> {
let row = sqlx::query_as::<_, FilamentRow>(
r#"INSERT INTO filaments (
id, user_id, material, brand, model, color_hex, spool_preset_id,
total_weight_g, net_weight_g, temp_hotend_c, temp_bed_c,
flow_factor_pct, notes, updated_at, created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING id, user_id, material, brand, model, color_hex, spool_preset_id,
total_weight_g, net_weight_g, temp_hotend_c, temp_bed_c,
flow_factor_pct, notes, updated_at, created_at"#,
)
.bind(filament.id)
.bind(filament.user_id)
.bind(filament.material.to_string())
.bind(&filament.brand)
.bind(&filament.model)
.bind(&filament.color_hex)
.bind(filament.spool_preset_id)
.bind(filament.total_weight_g)
.bind(filament.net_weight_g)
.bind(filament.temp_hotend_c)
.bind(filament.temp_bed_c)
.bind(filament.flow_factor_pct)
.bind(&filament.notes)
.bind(filament.updated_at)
.bind(filament.created_at)
.fetch_one(self.db.as_ref())
.await?;
Ok(Filament::from(row))
}
async fn update(&self, filament: &Filament) -> Result<Filament, AppError> {
let row = sqlx::query_as::<_, FilamentRow>(
r#"UPDATE filaments SET
material = $3, brand = $4, model = $5, color_hex = $6,
spool_preset_id = $7, total_weight_g = $8, net_weight_g = $9,
temp_hotend_c = $10, temp_bed_c = $11, flow_factor_pct = $12,
notes = $13, updated_at = $14
WHERE id = $1 AND user_id = $2
RETURNING id, user_id, material, brand, model, color_hex, spool_preset_id,
total_weight_g, net_weight_g, temp_hotend_c, temp_bed_c,
flow_factor_pct, notes, updated_at, created_at"#,
)
.bind(filament.id)
.bind(filament.user_id)
.bind(filament.material.to_string())
.bind(&filament.brand)
.bind(&filament.model)
.bind(&filament.color_hex)
.bind(filament.spool_preset_id)
.bind(filament.total_weight_g)
.bind(filament.net_weight_g)
.bind(filament.temp_hotend_c)
.bind(filament.temp_bed_c)
.bind(filament.flow_factor_pct)
.bind(&filament.notes)
.bind(filament.updated_at)
.fetch_one(self.db.as_ref())
.await?;
Ok(Filament::from(row))
}
async fn delete(&self, id: Uuid, user_id: Uuid) -> Result<(), AppError> {
sqlx::query("DELETE FROM filaments WHERE id = $1 AND user_id = $2")
.bind(id)
.bind(user_id)
.execute(self.db.as_ref())
.await?;
Ok(())
}
}
// -------------------------------------------------------------------------
// Mapeamento linha do banco → entidade de domínio
// -------------------------------------------------------------------------
#[derive(sqlx::FromRow)]
struct FilamentRow {
id: Uuid,
user_id: Uuid,
material: String,
brand: String,
model: Option<String>,
color_hex: String,
spool_preset_id: Uuid,
total_weight_g: i32,
net_weight_g: i32,
temp_hotend_c: Option<i32>,
temp_bed_c: Option<i32>,
flow_factor_pct: Option<f64>,
notes: Option<String>,
updated_at: time::OffsetDateTime,
created_at: time::OffsetDateTime,
}
impl From<FilamentRow> for Filament {
fn from(row: FilamentRow) -> Self {
Self {
id: row.id,
user_id: row.user_id,
material: Material::from(row.material),
brand: row.brand,
model: row.model,
color_hex: row.color_hex,
spool_preset_id: row.spool_preset_id,
total_weight_g: row.total_weight_g,
net_weight_g: row.net_weight_g,
temp_hotend_c: row.temp_hotend_c,
temp_bed_c: row.temp_bed_c,
flow_factor_pct: row.flow_factor_pct,
notes: row.notes,
updated_at: row.updated_at,
created_at: row.created_at,
}
}
}
@@ -0,0 +1,122 @@
use async_trait::async_trait;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use crate::{
domain::spool_preset::SpoolPreset,
error::AppError,
ports::SpoolPresetRepository,
};
pub struct PostgresSpoolPresetRepository {
db: Arc<PgPool>,
}
impl PostgresSpoolPresetRepository {
pub fn new(db: Arc<PgPool>) -> Self {
Self { db }
}
}
#[async_trait]
impl SpoolPresetRepository for PostgresSpoolPresetRepository {
async fn list_for_user(&self, user_id: Uuid) -> Result<Vec<SpoolPreset>, AppError> {
let rows = sqlx::query_as::<_, SpoolPresetRow>(
r#"SELECT id, name, spool_weight_g, is_system, user_id, created_at
FROM spool_presets
WHERE is_system = true OR user_id = $1
ORDER BY is_system DESC, name ASC"#,
)
.bind(user_id)
.fetch_all(self.db.as_ref())
.await?;
Ok(rows.into_iter().map(SpoolPreset::from).collect())
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<SpoolPreset>, AppError> {
let row = sqlx::query_as::<_, SpoolPresetRow>(
r#"SELECT id, name, spool_weight_g, is_system, user_id, created_at
FROM spool_presets WHERE id = $1"#,
)
.bind(id)
.fetch_optional(self.db.as_ref())
.await?;
Ok(row.map(SpoolPreset::from))
}
async fn create(&self, preset: &SpoolPreset) -> Result<SpoolPreset, AppError> {
let row = sqlx::query_as::<_, SpoolPresetRow>(
r#"INSERT INTO spool_presets (id, name, spool_weight_g, is_system, user_id, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, spool_weight_g, is_system, user_id, created_at"#,
)
.bind(preset.id)
.bind(&preset.name)
.bind(preset.spool_weight_g)
.bind(preset.is_system)
.bind(preset.user_id)
.bind(preset.created_at)
.fetch_one(self.db.as_ref())
.await?;
Ok(SpoolPreset::from(row))
}
async fn update(&self, preset: &SpoolPreset) -> Result<SpoolPreset, AppError> {
let row = sqlx::query_as::<_, SpoolPresetRow>(
r#"UPDATE spool_presets
SET name = $2, spool_weight_g = $3
WHERE id = $1 AND is_system = false
RETURNING id, name, spool_weight_g, is_system, user_id, created_at"#,
)
.bind(preset.id)
.bind(&preset.name)
.bind(preset.spool_weight_g)
.fetch_one(self.db.as_ref())
.await?;
Ok(SpoolPreset::from(row))
}
async fn delete(&self, id: Uuid, user_id: Uuid) -> Result<(), AppError> {
sqlx::query(
"DELETE FROM spool_presets WHERE id = $1 AND user_id = $2 AND is_system = false",
)
.bind(id)
.bind(user_id)
.execute(self.db.as_ref())
.await?;
Ok(())
}
}
// -------------------------------------------------------------------------
// Mapeamento linha do banco → entidade de domínio
// -------------------------------------------------------------------------
#[derive(sqlx::FromRow)]
struct SpoolPresetRow {
id: Uuid,
name: String,
spool_weight_g: i32,
is_system: bool,
user_id: Option<Uuid>,
created_at: time::OffsetDateTime,
}
impl From<SpoolPresetRow> for SpoolPreset {
fn from(row: SpoolPresetRow) -> Self {
Self {
id: row.id,
name: row.name,
spool_weight_g: row.spool_weight_g,
is_system: row.is_system,
user_id: row.user_id,
created_at: row.created_at,
}
}
}
@@ -0,0 +1,132 @@
use async_trait::async_trait;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use crate::{domain::user::User, error::AppError, ports::UserRepository};
pub struct PostgresUserRepository {
db: Arc<PgPool>,
}
impl PostgresUserRepository {
pub fn new(db: Arc<PgPool>) -> Self {
Self { db }
}
}
#[async_trait]
impl UserRepository for PostgresUserRepository {
async fn find_by_id(&self, id: Uuid) -> Result<Option<User>, AppError> {
let row = sqlx::query_as::<_, UserRow>(
r#"SELECT id, email, password_hash, google_id, email_verified, created_at, updated_at
FROM users WHERE id = $1"#,
)
.bind(id)
.fetch_optional(self.db.as_ref())
.await?;
Ok(row.map(User::from))
}
async fn find_by_email(&self, email: &str) -> Result<Option<User>, AppError> {
let row = sqlx::query_as::<_, UserRow>(
r#"SELECT id, email, password_hash, google_id, email_verified, created_at, updated_at
FROM users WHERE email = $1"#,
)
.bind(email)
.fetch_optional(self.db.as_ref())
.await?;
Ok(row.map(User::from))
}
async fn find_by_google_id(&self, google_id: &str) -> Result<Option<User>, AppError> {
let row = sqlx::query_as::<_, UserRow>(
r#"SELECT id, email, password_hash, google_id, email_verified, created_at, updated_at
FROM users WHERE google_id = $1"#,
)
.bind(google_id)
.fetch_optional(self.db.as_ref())
.await?;
Ok(row.map(User::from))
}
async fn create(&self, user: &User) -> Result<User, AppError> {
let row = sqlx::query_as::<_, UserRow>(
r#"INSERT INTO users (id, email, password_hash, google_id, email_verified, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, email, password_hash, google_id, email_verified, created_at, updated_at"#,
)
.bind(user.id)
.bind(&user.email)
.bind(&user.password_hash)
.bind(&user.google_id)
.bind(user.email_verified)
.bind(user.created_at)
.bind(user.updated_at)
.fetch_one(self.db.as_ref())
.await?;
Ok(User::from(row))
}
async fn update(&self, user: &User) -> Result<User, AppError> {
let row = sqlx::query_as::<_, UserRow>(
r#"UPDATE users
SET email = $2, password_hash = $3, google_id = $4,
email_verified = $5, updated_at = $6
WHERE id = $1
RETURNING id, email, password_hash, google_id, email_verified, created_at, updated_at"#,
)
.bind(user.id)
.bind(&user.email)
.bind(&user.password_hash)
.bind(&user.google_id)
.bind(user.email_verified)
.bind(user.updated_at)
.fetch_one(self.db.as_ref())
.await?;
Ok(User::from(row))
}
async fn verify_email(&self, id: Uuid) -> Result<(), AppError> {
sqlx::query("UPDATE users SET email_verified = true, updated_at = NOW() WHERE id = $1")
.bind(id)
.execute(self.db.as_ref())
.await?;
Ok(())
}
}
// -------------------------------------------------------------------------
// Mapeamento da linha do banco para entidade de domínio
// -------------------------------------------------------------------------
#[derive(sqlx::FromRow)]
struct UserRow {
id: Uuid,
email: String,
password_hash: Option<String>,
google_id: Option<String>,
email_verified: bool,
created_at: time::OffsetDateTime,
updated_at: time::OffsetDateTime,
}
impl From<UserRow> for User {
fn from(row: UserRow) -> Self {
Self {
id: row.id,
email: row.email,
password_hash: row.password_hash,
google_id: row.google_id,
email_verified: row.email_verified,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
}
+210
View File
@@ -0,0 +1,210 @@
use std::sync::Arc;
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::{
config::Config,
domain::user::User,
error::AppError,
ports::UserRepository,
};
/// Claims do JWT de acesso.
#[derive(Debug, Serialize, Deserialize)]
pub struct AccessClaims {
pub sub: Uuid,
pub email: String,
pub exp: i64,
pub iat: i64,
}
/// Claims do JWT de refresh.
#[derive(Debug, Serialize, Deserialize)]
pub struct RefreshClaims {
pub sub: Uuid,
pub exp: i64,
pub iat: i64,
}
/// Par de tokens emitido após login/registro bem-sucedido.
#[derive(Debug)]
pub struct TokenPair {
pub access_token: String,
pub refresh_token: String,
}
/// Caso de uso de autenticação: registro, login, OAuth, refresh e logout.
pub struct AuthService {
user_repo: Arc<dyn UserRepository>,
config: Config,
}
impl AuthService {
pub fn new(user_repo: Arc<dyn UserRepository>, config: Config) -> Self {
Self { user_repo, config }
}
/// Expõe o repositório de usuários para uso em outros handlers (ex: user_handler).
pub fn user_repo_ref(&self) -> &dyn UserRepository {
self.user_repo.as_ref()
}
// -------------------------------------------------------------------------
// Registro com email/senha
// -------------------------------------------------------------------------
pub async fn register(&self, email: String, password: String) -> Result<TokenPair, AppError> {
if let Some(_) = self.user_repo.find_by_email(&email).await? {
return Err(AppError::Conflict("email already registered".into()));
}
let password_hash = hash_password(&password)?;
let user = User::new_with_password(email, password_hash);
let user = self.user_repo.create(&user).await?;
self.issue_token_pair(&user)
}
// -------------------------------------------------------------------------
// Login com email/senha
// -------------------------------------------------------------------------
pub async fn login(&self, email: String, password: String) -> Result<TokenPair, AppError> {
let user = self
.user_repo
.find_by_email(&email)
.await?
.ok_or(AppError::Unauthorized)?;
let hash = user.password_hash.as_deref().ok_or(AppError::Unauthorized)?;
verify_password(&password, hash)?;
self.issue_token_pair(&user)
}
// -------------------------------------------------------------------------
// OAuth Google
// -------------------------------------------------------------------------
pub async fn google_oauth(&self, google_id: String, email: String) -> Result<TokenPair, AppError> {
let user = match self.user_repo.find_by_google_id(&google_id).await? {
Some(u) => u,
None => {
// Verificar se o email já existe (conta pode ter sido criada com email/senha)
match self.user_repo.find_by_email(&email).await? {
Some(mut u) => {
u.google_id = Some(google_id);
u.email_verified = true;
self.user_repo.update(&u).await?
}
None => {
let new_user = User::new_with_google(email, google_id);
self.user_repo.create(&new_user).await?
}
}
}
};
self.issue_token_pair(&user)
}
// -------------------------------------------------------------------------
// Refresh de token
// -------------------------------------------------------------------------
pub async fn refresh_token(&self, refresh_token: &str) -> Result<TokenPair, AppError> {
let claims = self.decode_refresh_token(refresh_token)?;
let user = self
.user_repo
.find_by_id(claims.sub)
.await?
.ok_or(AppError::Unauthorized)?;
self.issue_token_pair(&user)
}
// -------------------------------------------------------------------------
// Validação de access token (usado pelo middleware)
// -------------------------------------------------------------------------
pub fn validate_access_token(&self, token: &str) -> Result<AccessClaims, AppError> {
decode::<AccessClaims>(
token,
&DecodingKey::from_secret(self.config.jwt_secret.as_bytes()),
&Validation::default(),
)
.map(|data| data.claims)
.map_err(|_| AppError::Unauthorized)
}
// -------------------------------------------------------------------------
// Helpers privados
// -------------------------------------------------------------------------
fn issue_token_pair(&self, user: &User) -> Result<TokenPair, AppError> {
let now = OffsetDateTime::now_utc().unix_timestamp();
let access_exp = now + self.config.jwt_expiry_secs as i64;
let refresh_exp = now + self.config.jwt_refresh_expiry_secs as i64;
let access_claims = AccessClaims {
sub: user.id,
email: user.email.clone(),
exp: access_exp,
iat: now,
};
let refresh_claims = RefreshClaims {
sub: user.id,
exp: refresh_exp,
iat: now,
};
let key = EncodingKey::from_secret(self.config.jwt_secret.as_bytes());
let access_token = encode(&Header::default(), &access_claims, &key)
.map_err(|e| AppError::Internal(e.into()))?;
let refresh_token = encode(&Header::default(), &refresh_claims, &key)
.map_err(|e| AppError::Internal(e.into()))?;
Ok(TokenPair { access_token, refresh_token })
}
fn decode_refresh_token(&self, token: &str) -> Result<RefreshClaims, AppError> {
decode::<RefreshClaims>(
token,
&DecodingKey::from_secret(self.config.jwt_secret.as_bytes()),
&Validation::default(),
)
.map(|data| data.claims)
.map_err(|_| AppError::Unauthorized)
}
}
// -------------------------------------------------------------------------
// Funções utilitárias de hash (fora do impl para facilitar testes)
// -------------------------------------------------------------------------
pub fn hash_password(password: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password hashing failed: {e}")))
}
pub fn verify_password(password: &str, hash: &str) -> Result<(), AppError> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Invalid password hash: {e}")))?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.map_err(|_| AppError::Unauthorized)
}
+316
View File
@@ -0,0 +1,316 @@
use std::sync::Arc;
use base64::{engine::general_purpose::STANDARD, Engine};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::{
domain::filament::{Filament, Material},
error::AppError,
ports::{FilamentFilter, FilamentRepository, SpoolPresetRepository},
};
/// DTO de entrada para criação de filamento.
#[derive(Debug)]
pub struct CreateFilamentInput {
pub material: String,
pub brand: String,
pub model: Option<String>,
pub color_hex: String,
pub spool_preset_id: Uuid,
pub total_weight_g: i32,
pub temp_hotend_c: Option<i32>,
pub temp_bed_c: Option<i32>,
pub flow_factor_pct: Option<f64>,
pub notes: Option<String>,
}
/// DTO de entrada para atualização de filamento.
#[derive(Debug)]
pub struct UpdateFilamentInput {
pub material: String,
pub brand: String,
pub model: Option<String>,
pub color_hex: String,
pub spool_preset_id: Uuid,
pub total_weight_g: i32,
pub temp_hotend_c: Option<i32>,
pub temp_bed_c: Option<i32>,
pub flow_factor_pct: Option<f64>,
pub notes: Option<String>,
/// Timestamp do cliente para resolução de conflito offline-first
pub client_updated_at: Option<OffsetDateTime>,
}
/// Estatísticas do dashboard.
#[derive(Debug)]
pub struct DashboardStats {
pub total_stock_kg: f64,
pub low_stock_count: i64,
pub by_material: Vec<MaterialSummary>,
pub low_stock_filaments: Vec<Filament>,
pub recent_filaments: Vec<Filament>,
}
#[derive(Debug)]
pub struct MaterialSummary {
pub material: String,
pub count: i64,
pub total_kg: f64,
}
/// Casos de uso relacionados ao inventário de filamentos.
pub struct FilamentService {
repo: Arc<dyn FilamentRepository>,
preset_repo: Arc<dyn SpoolPresetRepository>,
}
impl FilamentService {
pub fn new(repo: Arc<dyn FilamentRepository>, preset_repo: Arc<dyn SpoolPresetRepository>) -> Self {
Self { repo, preset_repo }
}
// -------------------------------------------------------------------------
// CRUD
// -------------------------------------------------------------------------
pub async fn create(&self, user_id: Uuid, input: CreateFilamentInput) -> Result<Filament, AppError> {
let preset = self
.preset_repo
.find_by_id(input.spool_preset_id)
.await?
.ok_or(AppError::NotFound)?;
let net_weight_g = (input.total_weight_g - preset.spool_weight_g).max(0);
let now = OffsetDateTime::now_utc();
let filament = Filament {
id: Uuid::new_v4(),
user_id,
material: Material::from(input.material),
brand: input.brand,
model: input.model,
color_hex: input.color_hex,
spool_preset_id: input.spool_preset_id,
total_weight_g: input.total_weight_g,
net_weight_g,
temp_hotend_c: input.temp_hotend_c,
temp_bed_c: input.temp_bed_c,
flow_factor_pct: input.flow_factor_pct,
notes: input.notes,
updated_at: now,
created_at: now,
};
self.repo.create(&filament).await
}
pub async fn get(&self, id: Uuid, user_id: Uuid) -> Result<Filament, AppError> {
self.repo
.find_by_id(id, user_id)
.await?
.ok_or(AppError::NotFound)
}
pub async fn list(
&self,
user_id: Uuid,
filter: FilamentFilter,
) -> Result<Vec<Filament>, AppError> {
self.repo.list(user_id, filter).await
}
pub async fn update(
&self,
id: Uuid,
user_id: Uuid,
input: UpdateFilamentInput,
) -> Result<Filament, AppError> {
let mut existing = self
.repo
.find_by_id(id, user_id)
.await?
.ok_or(AppError::NotFound)?;
// Resolução de conflito offline-first: rejeita se servidor é mais recente
if let Some(client_ts) = input.client_updated_at {
if existing.updated_at > client_ts {
return Err(AppError::Conflict(
"server version is newer than client version".into(),
));
}
}
let preset = self
.preset_repo
.find_by_id(input.spool_preset_id)
.await?
.ok_or(AppError::NotFound)?;
let net_weight_g = (input.total_weight_g - preset.spool_weight_g).max(0);
existing.material = Material::from(input.material);
existing.brand = input.brand;
existing.model = input.model;
existing.color_hex = input.color_hex;
existing.spool_preset_id = input.spool_preset_id;
existing.total_weight_g = input.total_weight_g;
existing.net_weight_g = net_weight_g;
existing.temp_hotend_c = input.temp_hotend_c;
existing.temp_bed_c = input.temp_bed_c;
existing.flow_factor_pct = input.flow_factor_pct;
existing.notes = input.notes;
existing.updated_at = OffsetDateTime::now_utc();
self.repo.update(&existing).await
}
pub async fn delete(&self, id: Uuid, user_id: Uuid) -> Result<(), AppError> {
self.repo
.find_by_id(id, user_id)
.await?
.ok_or(AppError::NotFound)?;
self.repo.delete(id, user_id).await
}
// -------------------------------------------------------------------------
// Dashboard
// -------------------------------------------------------------------------
pub async fn dashboard(&self, user_id: Uuid) -> Result<DashboardStats, AppError> {
let all = self.repo.list(user_id, FilamentFilter::default()).await?;
let total_stock_kg = all.iter().map(|f| f.net_weight_g as f64).sum::<f64>() / 1000.0;
let low_stock_count = all.iter().filter(|f| f.is_low_stock()).count() as i64;
// Agrupamento por material
let mut by_material: std::collections::HashMap<String, (i64, f64)> = Default::default();
for f in &all {
let entry = by_material.entry(f.material.to_string()).or_default();
entry.0 += 1;
entry.1 += f.net_weight_g as f64 / 1000.0;
}
let mut by_material: Vec<MaterialSummary> = by_material
.into_iter()
.map(|(material, (count, total_kg))| MaterialSummary {
material,
count,
total_kg,
})
.collect();
by_material.sort_by(|a, b| b.count.cmp(&a.count));
let low_stock_filaments: Vec<Filament> = all
.iter()
.filter(|f| f.is_low_stock())
.cloned()
.collect();
let mut recent = all.clone();
recent.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let recent_filaments = recent.into_iter().take(5).collect();
Ok(DashboardStats {
total_stock_kg,
low_stock_count,
by_material,
low_stock_filaments,
recent_filaments,
})
}
// -------------------------------------------------------------------------
// QR Code
// -------------------------------------------------------------------------
pub async fn generate_qrcode_png(&self, id: Uuid, user_id: Uuid) -> Result<Vec<u8>, AppError> {
self.get(id, user_id).await?;
let deep_link = format!("meowspool://filaments/{id}");
let code = qrcode::QrCode::new(deep_link.as_bytes())
.map_err(|e| AppError::Internal(anyhow::anyhow!("QR Code generation failed: {e}")))?;
let image = code
.render::<image::Luma<u8>>()
.min_dimensions(200, 200)
.build();
let mut buf = Vec::new();
image::DynamicImage::ImageLuma8(image)
.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.map_err(|e| AppError::Internal(anyhow::anyhow!("PNG encoding failed: {e}")))?;
Ok(buf)
}
// -------------------------------------------------------------------------
// SVG Label
// -------------------------------------------------------------------------
pub async fn generate_label_svg(
&self,
id: Uuid,
user_id: Uuid,
width_mm: u32,
height_mm: u32,
) -> Result<String, AppError> {
let filament = self.get(id, user_id).await?;
// Gerar QR Code como PNG base64 para embutir no SVG
let qr_png = self.generate_qrcode_png(id, user_id).await?;
let qr_b64 = STANDARD.encode(&qr_png);
// Escala: 1mm = ~3.78px (96dpi)
let px_per_mm = 3.78_f64;
let width_px = (width_mm as f64 * px_per_mm) as u32;
let height_px = (height_mm as f64 * px_per_mm) as u32;
let qr_size = (height_px as f64 * 0.8) as u32;
let model_name = filament.model.as_deref().unwrap_or("");
let net_weight = filament.net_weight_g;
let material = filament.material.to_string();
let brand = &filament.brand;
let color = &filament.color_hex;
let svg = format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="{width_px}px" height="{height_px}px" viewBox="0 0 {width_px} {height_px}">
<rect width="{width_px}" height="{height_px}" fill="#1E1B18" rx="4"/>
<!-- Color swatch -->
<rect x="6" y="6" width="14" height="{h}" fill="{color}" rx="2"/>
<!-- Text block -->
<text x="26" y="20" font-family="Inter, sans-serif" font-size="10" font-weight="700" fill="#F5EEDC">{model_name}</text>
<text x="26" y="33" font-family="Inter, sans-serif" font-size="8" fill="#C9C1B0">{brand} · {material}</text>
<text x="26" y="46" font-family="Inter, sans-serif" font-size="9" font-weight="600" fill="#38BCC2">{net_weight}g</text>
<!-- QR Code -->
<image x="{qr_x}" y="{qr_y}" width="{qr_size}" height="{qr_size}"
xlink:href="data:image/png;base64,{qr_b64}"/>
</svg>"##,
width_px = width_px,
height_px = height_px,
h = height_px - 12,
color = color,
model_name = escape_xml(model_name),
brand = escape_xml(brand),
material = escape_xml(&material),
net_weight = net_weight,
qr_size = qr_size,
qr_x = width_px - qr_size - 4,
qr_y = (height_px - qr_size) / 2,
qr_b64 = qr_b64,
);
Ok(svg)
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+7
View File
@@ -0,0 +1,7 @@
pub mod auth_service;
pub mod filament_service;
pub mod spool_preset_service;
pub use auth_service::AuthService;
pub use filament_service::FilamentService;
pub use spool_preset_service::SpoolPresetService;
@@ -0,0 +1,72 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::{
domain::spool_preset::SpoolPreset,
error::AppError,
ports::SpoolPresetRepository,
};
/// Casos de uso para gerenciamento de presets de carretel.
pub struct SpoolPresetService {
repo: Arc<dyn SpoolPresetRepository>,
}
impl SpoolPresetService {
pub fn new(repo: Arc<dyn SpoolPresetRepository>) -> Self {
Self { repo }
}
/// Retorna presets do sistema + presets customizados do usuário.
pub async fn list_for_user(&self, user_id: Uuid) -> Result<Vec<SpoolPreset>, AppError> {
self.repo.list_for_user(user_id).await
}
pub async fn create(
&self,
user_id: Uuid,
name: String,
spool_weight_g: i32,
) -> Result<SpoolPreset, AppError> {
let preset = SpoolPreset::new_custom(name, spool_weight_g, user_id);
self.repo.create(&preset).await
}
pub async fn update(
&self,
id: Uuid,
user_id: Uuid,
name: String,
spool_weight_g: i32,
) -> Result<SpoolPreset, AppError> {
let mut preset = self
.repo
.find_by_id(id)
.await?
.ok_or(AppError::NotFound)?;
if !preset.is_editable_by(user_id) {
return Err(AppError::Forbidden);
}
preset.name = name;
preset.spool_weight_g = spool_weight_g;
self.repo.update(&preset).await
}
pub async fn delete(&self, id: Uuid, user_id: Uuid) -> Result<(), AppError> {
let preset = self
.repo
.find_by_id(id)
.await?
.ok_or(AppError::NotFound)?;
if !preset.is_editable_by(user_id) {
return Err(AppError::Forbidden);
}
self.repo.delete(id, user_id).await
}
}
+72
View File
@@ -0,0 +1,72 @@
use anyhow::Context;
/// Configuração central da aplicação, lida de variáveis de ambiente.
#[derive(Debug, Clone)]
pub struct Config {
pub database_url: String,
pub jwt_secret: String,
pub jwt_expiry_secs: u64,
pub jwt_refresh_expiry_secs: u64,
pub google_client_id: String,
pub google_client_secret: String,
pub host: String,
pub port: u16,
pub app_env: AppEnv,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AppEnv {
Development,
Production,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
Ok(Self {
database_url: required("DATABASE_URL")?,
jwt_secret: required("JWT_SECRET")?,
jwt_expiry_secs: optional("JWT_EXPIRY_SECS", 3600)?,
jwt_refresh_expiry_secs: optional("JWT_REFRESH_EXPIRY_SECS", 2_592_000)?,
google_client_id: optional_string("GOOGLE_CLIENT_ID"),
google_client_secret: optional_string("GOOGLE_CLIENT_SECRET"),
host: optional_string_default("HOST", "0.0.0.0"),
port: optional("PORT", 8080)?,
app_env: parse_app_env(),
})
}
pub fn is_production(&self) -> bool {
self.app_env == AppEnv::Production
}
}
fn required(key: &str) -> anyhow::Result<String> {
std::env::var(key).with_context(|| format!("Missing required env var: {key}"))
}
fn optional<T: std::str::FromStr>(key: &str, default: T) -> anyhow::Result<T>
where
T::Err: std::fmt::Debug,
{
match std::env::var(key) {
Ok(val) => val
.parse::<T>()
.map_err(|e| anyhow::anyhow!("Invalid value for {key}: {e:?}")),
Err(_) => Ok(default),
}
}
fn optional_string(key: &str) -> String {
std::env::var(key).unwrap_or_default()
}
fn optional_string_default(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}
fn parse_app_env() -> AppEnv {
match std::env::var("APP_ENV").as_deref() {
Ok("production") => AppEnv::Production,
_ => AppEnv::Development,
}
}
+99
View File
@@ -0,0 +1,99 @@
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
/// Entidade de domínio que representa um carretel de filamento no inventário.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Filament {
pub id: Uuid,
pub user_id: Uuid,
pub material: Material,
pub brand: String,
pub model: Option<String>,
/// Cor em formato hex, ex: "#FF5733"
pub color_hex: String,
pub spool_preset_id: Uuid,
/// Peso total medido na balança (gramas)
pub total_weight_g: i32,
/// Calculado: total_weight_g - preset.spool_weight_g
pub net_weight_g: i32,
/// Temperatura do hotend em °C (opcional)
pub temp_hotend_c: Option<i32>,
/// Temperatura da mesa em °C (opcional)
pub temp_bed_c: Option<i32>,
/// Fator de fluxo/extrusão em % (opcional)
pub flow_factor_pct: Option<f64>,
pub notes: Option<String>,
pub updated_at: OffsetDateTime,
pub created_at: OffsetDateTime,
}
/// Tipos de material suportados pelo sistema.
/// Armazenado como TEXT no banco de dados.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Material {
#[serde(rename = "PLA")]
Pla,
#[serde(rename = "ABS")]
Abs,
#[serde(rename = "PETG")]
Petg,
#[serde(rename = "TPU")]
Tpu,
#[serde(rename = "ASA")]
Asa,
#[serde(rename = "PA")]
Pa,
#[serde(rename = "PC")]
Pc,
/// Outros materiais não listados acima
Other(String),
}
impl std::fmt::Display for Material {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pla => write!(f, "PLA"),
Self::Abs => write!(f, "ABS"),
Self::Petg => write!(f, "PETG"),
Self::Tpu => write!(f, "TPU"),
Self::Asa => write!(f, "ASA"),
Self::Pa => write!(f, "PA"),
Self::Pc => write!(f, "PC"),
Self::Other(s) => write!(f, "{s}"),
}
}
}
impl From<String> for Material {
fn from(s: String) -> Self {
match s.to_uppercase().as_str() {
"PLA" => Self::Pla,
"ABS" => Self::Abs,
"PETG" => Self::Petg,
"TPU" => Self::Tpu,
"ASA" => Self::Asa,
"PA" => Self::Pa,
"PC" => Self::Pc,
other => Self::Other(other.to_string()),
}
}
}
impl Filament {
/// Calcula o percentual de filamento restante em relação ao peso nominal de 1kg.
/// Usado para alertas de estoque: <=15% = crítico, <=35% = baixo.
pub fn stock_percentage(&self) -> u8 {
let nominal_g = 1000_f64;
let pct = (self.net_weight_g as f64 / nominal_g * 100.0).round() as i32;
pct.clamp(0, 100) as u8
}
pub fn is_low_stock(&self) -> bool {
self.stock_percentage() <= 35
}
pub fn is_critical_stock(&self) -> bool {
self.stock_percentage() <= 15
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod filament;
pub mod spool_preset;
pub mod user;
+48
View File
@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
/// Entidade de domínio que representa um preset de carretel.
/// Presets do sistema têm `is_system = true` e `user_id = None`.
/// Presets customizados têm `is_system = false` e `user_id = Some(uuid)`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpoolPreset {
pub id: Uuid,
pub name: String,
/// Peso do carretel vazio em gramas
pub spool_weight_g: i32,
/// true = preset built-in do sistema (somente leitura)
pub is_system: bool,
/// None para presets do sistema
pub user_id: Option<Uuid>,
pub created_at: OffsetDateTime,
}
impl SpoolPreset {
pub fn new_system(name: String, spool_weight_g: i32) -> Self {
Self {
id: Uuid::new_v4(),
name,
spool_weight_g,
is_system: true,
user_id: None,
created_at: OffsetDateTime::now_utc(),
}
}
pub fn new_custom(name: String, spool_weight_g: i32, user_id: Uuid) -> Self {
Self {
id: Uuid::new_v4(),
name,
spool_weight_g,
is_system: false,
user_id: Some(user_id),
created_at: OffsetDateTime::now_utc(),
}
}
/// Retorna true se o usuário informado pode editar ou deletar este preset.
pub fn is_editable_by(&self, user_id: Uuid) -> bool {
!self.is_system && self.user_id == Some(user_id)
}
}
+62
View File
@@ -0,0 +1,62 @@
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
/// Entidade de domínio que representa um usuário da plataforma.
/// Esta struct não tem dependências de infraestrutura — apenas tipos base.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: Uuid,
pub email: String,
/// Hash bcrypt/argon2. None quando o usuário usa OAuth exclusivamente.
pub password_hash: Option<String>,
/// ID do usuário no Google OAuth. None quando usa email/senha.
pub google_id: Option<String>,
pub email_verified: bool,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
/// Provedor de autenticação utilizado pelo usuário.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthProvider {
EmailPassword,
Google,
}
impl User {
pub fn new_with_password(email: String, password_hash: String) -> Self {
let now = OffsetDateTime::now_utc();
Self {
id: Uuid::new_v4(),
email,
password_hash: Some(password_hash),
google_id: None,
email_verified: false,
created_at: now,
updated_at: now,
}
}
pub fn new_with_google(email: String, google_id: String) -> Self {
let now = OffsetDateTime::now_utc();
Self {
id: Uuid::new_v4(),
email,
password_hash: None,
google_id: Some(google_id),
email_verified: true,
created_at: now,
updated_at: now,
}
}
pub fn auth_provider(&self) -> AuthProvider {
if self.google_id.is_some() {
AuthProvider::Google
} else {
AuthProvider::EmailPassword
}
}
}
+78
View File
@@ -0,0 +1,78 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
/// Erro centralizado da aplicação.
/// Toda camada retorna `Result<T, AppError>`.
#[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("unprocessable entity: {0}")]
UnprocessableEntity(String),
#[error("internal error")]
Internal(#[from] anyhow::Error),
}
impl AppError {
fn status_and_code(&self) -> (StatusCode, &'static str) {
match self {
Self::NotFound => (StatusCode::NOT_FOUND, "NOT_FOUND"),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED"),
Self::Forbidden => (StatusCode::FORBIDDEN, "FORBIDDEN"),
Self::Validation(_) => (StatusCode::BAD_REQUEST, "VALIDATION_ERROR"),
Self::Conflict(_) => (StatusCode::CONFLICT, "CONFLICT"),
Self::UnprocessableEntity(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UNPROCESSABLE_ENTITY"),
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR"),
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = self.status_and_code();
if status == StatusCode::INTERNAL_SERVER_ERROR {
tracing::error!(error = %self, "Internal server error");
}
let body = Json(json!({
"error": self.to_string(),
"code": code,
}));
(status, body).into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
match err {
sqlx::Error::RowNotFound => Self::NotFound,
_ => Self::Internal(err.into()),
}
}
}
impl From<validator::ValidationErrors> for AppError {
fn from(err: validator::ValidationErrors) -> Self {
Self::Validation(err.to_string())
}
}
+43
View File
@@ -0,0 +1,43 @@
use dotenvy::dotenv;
use sqlx::postgres::PgPoolOptions;
use std::net::SocketAddr;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
mod adapters;
mod application;
mod config;
mod domain;
mod error;
mod ports;
mod router;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv().ok();
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "meowspool=debug,tower_http=debug".into()))
.with(tracing_subscriber::fmt::layer())
.init();
let config = config::Config::from_env()?;
tracing::info!("Connecting to database...");
let db_pool = PgPoolOptions::new()
.max_connections(10)
.connect(&config.database_url)
.await?;
tracing::info!("Running migrations...");
sqlx::migrate!("./migrations").run(&db_pool).await?;
let app = router::build(db_pool, config.clone());
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
tracing::info!("Server listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
+43
View File
@@ -0,0 +1,43 @@
use async_trait::async_trait;
use uuid::Uuid;
use crate::{domain::filament::Filament, error::AppError};
/// Filtros disponíveis para listagem de filamentos.
#[derive(Debug, Default, Clone)]
pub struct FilamentFilter {
pub material: Option<String>,
pub brand: Option<String>,
/// Busca full-text em marca, modelo e notas
pub search: Option<String>,
/// "low" (<=15%), "medium" (<=35%), "ok" (>35%)
pub stock_level: Option<String>,
pub sort: Option<SortOrder>,
pub page: u32,
pub per_page: u32,
}
#[derive(Debug, Clone)]
pub enum SortOrder {
NetWeightAsc,
NetWeightDesc,
CreatedAtDesc,
}
/// Port (interface) para operações de persistência de filamentos.
/// Implementado pelo adapter `PostgresFilamentRepository`.
#[async_trait]
pub trait FilamentRepository: Send + Sync {
/// Busca um filamento pelo ID, garantindo que pertence ao user_id informado.
async fn find_by_id(&self, id: Uuid, user_id: Uuid) -> Result<Option<Filament>, AppError>;
/// Lista filamentos do usuário com filtros e paginação.
async fn list(&self, user_id: Uuid, filter: FilamentFilter) -> Result<Vec<Filament>, AppError>;
/// Conta o total de filamentos (para paginação).
async fn count(&self, user_id: Uuid, filter: &FilamentFilter) -> Result<i64, 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>;
}
+7
View File
@@ -0,0 +1,7 @@
pub mod filament_repository;
pub mod spool_preset_repository;
pub mod user_repository;
pub use filament_repository::{FilamentFilter, FilamentRepository};
pub use spool_preset_repository::SpoolPresetRepository;
pub use user_repository::UserRepository;
@@ -0,0 +1,18 @@
use async_trait::async_trait;
use uuid::Uuid;
use crate::{domain::spool_preset::SpoolPreset, error::AppError};
/// Port (interface) para operações de persistência de presets de carretel.
/// Implementado pelo adapter `PostgresSpoolPresetRepository`.
#[async_trait]
pub trait SpoolPresetRepository: Send + Sync {
/// Retorna todos os presets do sistema mais os presets customizados do usuário.
async fn list_for_user(&self, user_id: Uuid) -> Result<Vec<SpoolPreset>, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<Option<SpoolPreset>, AppError>;
async fn create(&self, preset: &SpoolPreset) -> Result<SpoolPreset, AppError>;
async fn update(&self, preset: &SpoolPreset) -> Result<SpoolPreset, AppError>;
async fn delete(&self, id: Uuid, user_id: Uuid) -> Result<(), AppError>;
}
+16
View File
@@ -0,0 +1,16 @@
use async_trait::async_trait;
use uuid::Uuid;
use crate::{domain::user::User, error::AppError};
/// Port (interface) para operações de persistência de usuários.
/// Implementado pelo adapter `PostgresUserRepository`.
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: Uuid) -> Result<Option<User>, AppError>;
async fn find_by_email(&self, email: &str) -> Result<Option<User>, AppError>;
async fn find_by_google_id(&self, google_id: &str) -> Result<Option<User>, AppError>;
async fn create(&self, user: &User) -> Result<User, AppError>;
async fn update(&self, user: &User) -> Result<User, AppError>;
async fn verify_email(&self, id: Uuid) -> Result<(), AppError>;
}
+100
View File
@@ -0,0 +1,100 @@
use axum::{middleware, routing::{delete, get, post, put}, Router};
use sqlx::PgPool;
use std::sync::Arc;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use crate::{
adapters::{
inbound::{
auth_handler,
filament_handler,
spool_preset_handler,
user_handler,
middleware::auth::auth_middleware,
},
outbound::{
PostgresFilamentRepository,
PostgresSpoolPresetRepository,
PostgresUserRepository,
},
},
application::{AuthService, FilamentService, SpoolPresetService},
config::Config,
ports::{FilamentRepository, SpoolPresetRepository, UserRepository},
};
/// Estado compartilhado injetado em todos os handlers via Axum State.
#[derive(Clone)]
pub struct AppState {
pub auth_service: Arc<AuthService>,
pub filament_service: Arc<FilamentService>,
pub spool_preset_service: Arc<SpoolPresetService>,
pub config: Config,
}
/// Constrói o router Axum com todas as rotas e middlewares.
pub fn build(db: PgPool, config: Config) -> Router {
let db = Arc::new(db);
// Repositórios (outbound adapters)
let user_repo: Arc<dyn UserRepository> = Arc::new(PostgresUserRepository::new(Arc::clone(&db)));
let filament_repo: Arc<dyn FilamentRepository> = Arc::new(PostgresFilamentRepository::new(Arc::clone(&db)));
let preset_repo: Arc<dyn SpoolPresetRepository> = Arc::new(PostgresSpoolPresetRepository::new(Arc::clone(&db)));
// Serviços (application layer)
let auth_service = Arc::new(AuthService::new(
Arc::clone(&user_repo),
config.clone(),
));
let filament_service = Arc::new(FilamentService::new(
Arc::clone(&filament_repo),
Arc::clone(&preset_repo),
));
let spool_preset_service = Arc::new(SpoolPresetService::new(Arc::clone(&preset_repo)));
let state = AppState {
auth_service,
filament_service,
spool_preset_service,
config: config.clone(),
};
// Rotas públicas (sem autenticação)
let public_routes = Router::new()
.route("/auth/register", post(auth_handler::register_handler))
.route("/auth/login", post(auth_handler::login_handler))
.route("/auth/oauth/google", post(auth_handler::google_oauth_handler))
.route("/auth/refresh", post(auth_handler::refresh_token_handler))
.route("/auth/forgot-password", post(auth_handler::forgot_password_handler))
.route("/auth/verify-email", post(auth_handler::verify_email_handler))
.route("/auth/reset-password", post(auth_handler::reset_password_handler));
// Rotas protegidas (requerem JWT válido)
let protected_routes = Router::new()
.route("/auth/logout", post(auth_handler::logout_handler))
// Users
.route("/users/me", get(user_handler::get_me_handler))
.route("/users/me", put(user_handler::update_me_handler))
// Dashboard
.route("/dashboard", get(filament_handler::dashboard_handler))
// Filaments
.route("/filaments", get(filament_handler::list_filaments_handler))
.route("/filaments", post(filament_handler::create_filament_handler))
.route("/filaments/:id", get(filament_handler::get_filament_handler))
.route("/filaments/:id", put(filament_handler::update_filament_handler))
.route("/filaments/:id", delete(filament_handler::delete_filament_handler))
.route("/filaments/:id/qrcode", get(filament_handler::get_qrcode_handler))
.route("/filaments/:id/label.svg", get(filament_handler::export_label_handler))
// Spool Presets
.route("/spool-presets", get(spool_preset_handler::list_presets_handler))
.route("/spool-presets", post(spool_preset_handler::create_preset_handler))
.route("/spool-presets/:id", put(spool_preset_handler::update_preset_handler))
.route("/spool-presets/:id", delete(spool_preset_handler::delete_preset_handler))
.route_layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
Router::new()
.nest("/api/v1", public_routes.merge(protected_routes))
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
}