From 0d2e59a9ebd18950ef732cf01dc228722c25fe9f Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 28 May 2026 04:01:44 +0000 Subject: [PATCH] feat: implement category service with CRUD operations and validation --- .../src/services/transaction/category/dto.rs | 165 ++++++++++++++++++ .../src/services/transaction/category/mod.rs | 6 + .../src/services/transaction/category/repo.rs | 124 +++++++++++++ .../services/transaction/category/service.rs | 115 ++++++++++++ 4 files changed, 410 insertions(+) create mode 100644 src-tauri/src/services/transaction/category/dto.rs create mode 100644 src-tauri/src/services/transaction/category/mod.rs create mode 100644 src-tauri/src/services/transaction/category/repo.rs create mode 100644 src-tauri/src/services/transaction/category/service.rs diff --git a/src-tauri/src/services/transaction/category/dto.rs b/src-tauri/src/services/transaction/category/dto.rs new file mode 100644 index 0000000..de17f9f --- /dev/null +++ b/src-tauri/src/services/transaction/category/dto.rs @@ -0,0 +1,165 @@ +use std::sync::Arc; + +use sea_orm::ActiveValue::Set; + +use crate::{ + db::entities::category::{ActiveModel as CategoryActiveModel, Model as CategoryModel}, + services::transaction::category::{repo::CategoryRepoService, CategoryServiceError}, +}; + +pub struct Category { + pub id: i64, + pub name: String, + pub color_code: String, + pub parent_id: Option, + pub created_at: String, + pub updated_at: String, +} + +impl From for Category { + fn from(model: CategoryModel) -> Self { + Self { + id: model.id, + name: model.name, + color_code: model.color_code, + parent_id: model.parent_id, + created_at: model.created_at.to_string(), + updated_at: model.updated_at.to_string(), + } + } +} + +pub struct CategoryWithParent { + pub id: i64, + pub name: String, + pub color_code: String, + pub parent_id: Option, + pub parent: Option>, + pub created_at: String, + pub updated_at: String, +} + +impl From<(CategoryModel, Option)> for CategoryWithParent { + fn from((model, parent): (CategoryModel, Option)) -> Self { + Self { + id: model.id, + name: model.name, + color_code: model.color_code, + parent_id: model.parent_id, + parent: parent.map(|parent_model| Box::new(parent_model.into())), + created_at: model.created_at.to_string(), + updated_at: model.updated_at.to_string(), + } + } +} + +pub struct CreateCategoryRequest { + pub name: String, + pub color_code: String, + pub parent_id: Option, +} + +impl CreateCategoryRequest { + pub async fn validate( + &self, + category_repo: Arc, + ) -> std::result::Result<(), CategoryServiceError> + where + T: CategoryRepoService + ?Sized, + { + if self.name.trim().is_empty() { + return Err(CategoryServiceError::Validation( + "Name cannot be empty".to_string(), + )); + } + + if self.color_code.trim().is_empty() { + return Err(CategoryServiceError::Validation( + "Color code cannot be empty".to_string(), + )); + } + + if let Some(parent_id) = self.parent_id { + let res = category_repo.get_category(parent_id).await?; + if res.is_none() { + return Err(CategoryServiceError::Validation(format!( + "Parent category with id {} does not exist", + parent_id + ))); + } + } + + Ok(()) + } +} + +impl From for CategoryActiveModel { + fn from(request: CreateCategoryRequest) -> Self { + Self { + name: Set(request.name), + color_code: Set(request.color_code), + parent_id: Set(request.parent_id), + created_at: Set(chrono::Utc::now().to_rfc3339()), + updated_at: Set(chrono::Utc::now().to_rfc3339()), + ..Default::default() + } + } +} + +pub struct UpdateCategoryRequest { + pub name: Option, + pub color_code: Option, + pub parent_id: Option>, +} + +impl UpdateCategoryRequest { + pub async fn validate( + &self, + category_repo: Arc, + ) -> std::result::Result<(), CategoryServiceError> + where + T: CategoryRepoService + ?Sized, + { + if let Some(name) = &self.name { + if name.trim().is_empty() { + return Err(CategoryServiceError::Validation( + "Name cannot be empty".to_string(), + )); + } + } + + if let Some(color_code) = &self.color_code { + if color_code.trim().is_empty() { + return Err(CategoryServiceError::Validation( + "Color code cannot be empty".to_string(), + )); + } + } + + if let Some(Some(parent_id)) = self.parent_id { + let res = category_repo.get_category(parent_id).await?; + if res.is_none() { + return Err(CategoryServiceError::Validation(format!( + "Parent category with id {} does not exist", + parent_id + ))); + } + } + + Ok(()) + } + + pub fn apply_to(self, category: &mut CategoryActiveModel) { + if let Some(name) = self.name { + category.name = Set(name); + } + if let Some(color_code) = self.color_code { + category.color_code = Set(color_code); + } + if let Some(Some(parent_id)) = self.parent_id { + category.parent_id = Set(Some(parent_id)); + } else if let Some(None) = self.parent_id { + category.parent_id = Set(None); + } + } +} diff --git a/src-tauri/src/services/transaction/category/mod.rs b/src-tauri/src/services/transaction/category/mod.rs new file mode 100644 index 0000000..df05e03 --- /dev/null +++ b/src-tauri/src/services/transaction/category/mod.rs @@ -0,0 +1,6 @@ +mod dto; +pub(super) mod repo; +mod service; + +pub use dto::*; +pub use service::*; diff --git a/src-tauri/src/services/transaction/category/repo.rs b/src-tauri/src/services/transaction/category/repo.rs new file mode 100644 index 0000000..3f8cdcd --- /dev/null +++ b/src-tauri/src/services/transaction/category/repo.rs @@ -0,0 +1,124 @@ +use std::sync::Arc; + +use crate::{ + db::{ + entities::category::{ActiveModel as CategoryActiveModel, Entity as CategoryEntity}, + Db, + }, + services::transaction::category::{ + Category, CategoryServiceError, CategoryServiceResult, CategoryWithParent, + CreateCategoryRequest, UpdateCategoryRequest, + }, +}; +use sea_orm::{ + ActiveModelTrait, DatabaseConnection, EntityTrait, Linked, ModelTrait, RelationTrait, +}; + +pub type Result = CategoryServiceResult; + +#[async_trait::async_trait] +pub trait CategoryRepoService: Send + Sync + 'static { + async fn get_categories(&self) -> Result>; + async fn get_category(&self, id: i64) -> Result>; + async fn create_category(&self, request: CreateCategoryRequest) -> Result; + async fn create_category_with_tx(&self, request: CreateCategoryRequest, tx: &Db) + -> Result; + async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()>; + async fn update_category_with_tx( + &self, + id: i64, + request: UpdateCategoryRequest, + tx: &Db, + ) -> Result<()>; + async fn delete_category(&self, id: i64) -> Result<()>; +} + +#[derive(Clone)] +pub struct CategoryRepoServiceImpl { + pub db: DatabaseConnection, +} + +struct CategorySelfRefLink; + +impl Linked for CategorySelfRefLink { + type FromEntity = CategoryEntity; + type ToEntity = CategoryEntity; + + fn link(&self) -> Vec { + vec![ + // Use the BelongsTo or HasMany relation you defined in your enum + crate::db::entities::category::Relation::SelfRef.def(), + ] + } +} + +#[async_trait::async_trait] +impl CategoryRepoService for CategoryRepoServiceImpl { + async fn get_categories(&self) -> Result> { + let categories = CategoryEntity::find().all(&self.db).await?; + Ok(categories.into_iter().map(|model| model.into()).collect()) + } + + async fn get_category(&self, id: i64) -> Result> { + let category = CategoryEntity::find_by_id(id) + .find_also_linked(CategorySelfRefLink) + .one(&self.db) + .await?; + + Ok(category.map(|res| res.into())) + } + + async fn create_category(&self, request: CreateCategoryRequest) -> Result { + self.create_category_with_tx(request, &(&self.db).into()) + .await + } + + async fn create_category_with_tx( + &self, + request: CreateCategoryRequest, + tx: &Db, + ) -> Result { + request.validate(Arc::new(self.clone())).await?; + + let new_category: CategoryActiveModel = request.into(); + let res = new_category.insert(tx).await?; + Ok(res.id) + } + + async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()> { + self.update_category_with_tx(id, request, &(&self.db).into()) + .await + } + + async fn update_category_with_tx( + &self, + id: i64, + request: UpdateCategoryRequest, + tx: &Db, + ) -> Result<()> { + let category = CategoryEntity::find_by_id(id) + .one(tx) + .await? + .ok_or_else(|| { + CategoryServiceError::TargetNotFound(format!("Category with id {} not found", id)) + })?; + let mut category: CategoryActiveModel = category.into(); + request.validate(Arc::new(self.clone())).await?; + + request.apply_to(&mut category); + + category.update(tx).await?; + Ok(()) + } + + async fn delete_category(&self, id: i64) -> Result<()> { + let category = CategoryEntity::find_by_id(id) + .one(&self.db) + .await? + .ok_or_else(|| { + CategoryServiceError::TargetNotFound(format!("Category with id {} not found", id)) + })?; + category.delete(&self.db).await?; + Ok(()) + } +} diff --git a/src-tauri/src/services/transaction/category/service.rs b/src-tauri/src/services/transaction/category/service.rs new file mode 100644 index 0000000..1081894 --- /dev/null +++ b/src-tauri/src/services/transaction/category/service.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; + +use sea_orm::DatabaseConnection; +use thiserror::Error; + +use crate::{ + db::{Db, DbServiceError}, + services::transaction::category::{ + repo::{CategoryRepoService, CategoryRepoServiceImpl}, + Category, CategoryWithParent, CreateCategoryRequest, UpdateCategoryRequest, + }, +}; + +#[derive(Error, Debug)] +pub enum CategoryServiceError { + #[error("database error: {0}")] + DbErr(#[from] DbServiceError), + #[error("Target not found: {0}")] + TargetNotFound(String), + #[error("Validation error: {0}")] + Validation(String), + #[error("unknown error: {0}")] + Unknown(String), +} + +impl From for CategoryServiceError { + fn from(err: sea_orm::DbErr) -> Self { + CategoryServiceError::DbErr(DbServiceError::from(err)) + } +} + +pub type CategoryServiceResult = std::result::Result; + +type Result = CategoryServiceResult; + +#[async_trait::async_trait] +pub trait CategoryService: Send + Sync + 'static { + async fn get_categories(&self) -> Result>; + async fn get_category(&self, id: i64) -> Result>; + async fn create_category(&self, request: CreateCategoryRequest) -> Result; + async fn create_category_with_tx(&self, request: CreateCategoryRequest, tx: &Db) + -> Result; + async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()>; + async fn update_category_with_tx( + &self, + id: i64, + request: UpdateCategoryRequest, + tx: &Db, + ) -> Result<()>; + async fn delete_category(&self, id: i64) -> Result<()>; +} + +pub struct CategoryServiceImpl +where + T: CategoryRepoService + ?Sized, +{ + db: DatabaseConnection, + category_repo: Arc, +} + +impl CategoryServiceImpl { + pub fn new(db: DatabaseConnection) -> Self { + Self { + db: db.clone(), + category_repo: Arc::new(CategoryRepoServiceImpl { db }), + } + } +} + +#[async_trait::async_trait] +impl CategoryService for CategoryServiceImpl +where + T: CategoryRepoService + ?Sized, +{ + async fn get_categories(&self) -> Result> { + self.category_repo.get_categories().await + } + + async fn get_category(&self, id: i64) -> Result> { + self.category_repo.get_category(id).await + } + + async fn create_category(&self, request: CreateCategoryRequest) -> Result { + self.category_repo.create_category(request).await + } + + async fn create_category_with_tx( + &self, + request: CreateCategoryRequest, + tx: &Db, + ) -> Result { + self.category_repo + .create_category_with_tx(request, tx) + .await + } + + async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()> { + self.category_repo.update_category(id, request).await + } + + async fn update_category_with_tx( + &self, + id: i64, + request: UpdateCategoryRequest, + tx: &Db, + ) -> Result<()> { + self.category_repo + .update_category_with_tx(id, request, tx) + .await + } + + async fn delete_category(&self, id: i64) -> Result<()> { + self.category_repo.delete_category(id).await + } +}