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:
@@ -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,
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user