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, pub brand: Option, pub search: Option, pub stock_level: Option, pub sort: Option, #[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, /// Campos a incluir, separados por vírgula: color,name,material_brand,net_weight,print_temp,qrcode /// Se ausente, inclui todos. pub fields: Option, /// DPI alvo da impressora. Padrão: 96 (tela). Use 203 para Niimbot/térmicas de baixo custo, /// 300 para Brother/Dymo premium. Afeta resolução do QR Code no SVG e dimensionamento físico. pub dpi: Option, } 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, #[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, pub temp_bed_c: Option, pub flow_factor_pct: Option, #[validate(length(max = 1000))] pub notes: Option, } #[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, #[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, pub temp_bed_c: Option, pub flow_factor_pct: Option, #[validate(length(max = 1000))] pub notes: Option, pub client_updated_at: Option, } #[derive(Debug, Serialize)] pub struct FilamentResponse { pub id: Uuid, pub user_id: Uuid, pub material: String, pub brand: String, pub model: Option, 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, pub temp_bed_c: Option, pub flow_factor_pct: Option, pub notes: Option, pub updated_at: OffsetDateTime, pub created_at: OffsetDateTime, } impl From 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, pub low_stock_filaments: Vec, pub recent_filaments: Vec, } #[derive(Debug, Serialize)] pub struct MaterialSummaryResponse { pub material: String, pub count: i64, pub total_kg: f64, } impl From for MaterialSummaryResponse { fn from(m: MaterialSummary) -> Self { Self { material: m.material, count: m.count, total_kg: m.total_kg, } } } impl From 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, Extension(user): Extension, ) -> Result { let stats = state.filament_service.dashboard(user.id).await?; Ok(Json(DashboardResponse::from(stats))) } pub async fn list_filaments_handler( State(state): State, Extension(user): Extension, Query(query): Query, ) -> Result { 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 = filaments.into_iter().map(Into::into).collect(); Ok(Json(response)) } pub async fn create_filament_handler( State(state): State, Extension(user): Extension, Json(req): Json, ) -> Result { 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, Extension(user): Extension, Path(id): Path, ) -> Result { let filament = state.filament_service.get(id, user.id).await?; Ok(Json(FilamentResponse::from(filament))) } pub async fn update_filament_handler( State(state): State, Extension(user): Extension, Path(id): Path, Json(req): Json, ) -> Result { 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, Extension(user): Extension, Path(id): Path, ) -> Result { state.filament_service.delete(id, user.id).await?; Ok(StatusCode::NO_CONTENT) } pub async fn get_qrcode_handler( State(state): State, Extension(user): Extension, Path(id): Path, ) -> Result { let png = state.filament_service.generate_qrcode_png(id, user.id).await?; Ok(( [(header::CONTENT_TYPE, "image/png")], png, )) } pub async fn export_label_pdf_handler( State(state): State, Extension(user): Extension, Path(id): Path, Query(query): Query, ) -> Result { let pdf = state .filament_service .generate_label_pdf(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref(), query.dpi) .await?; let filename = format!("meowspool-label-{id}.pdf"); Ok(( [ (header::CONTENT_TYPE, "application/pdf".to_string()), ( header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\""), ), ], pdf, )) } pub async fn export_label_handler( State(state): State, Extension(user): Extension, Path(id): Path, Query(query): Query, ) -> Result { let svg = state .filament_service .generate_label_svg(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref(), query.dpi) .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, )) }