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,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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod inbound;
|
||||
pub mod outbound;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod filament;
|
||||
pub mod spool_preset;
|
||||
pub mod user;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user