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
@@ -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())
}