From 26c308fcb617281cf926e2bb2a1f5005e7ce8c5a Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 28 May 2026 04:01:37 +0000 Subject: [PATCH] feat: implement tag service with CRUD operations and validation --- src-tauri/src/services/transaction/tag/dto.rs | 84 ++++++++++ src-tauri/src/services/transaction/tag/mod.rs | 6 + .../src/services/transaction/tag/repo.rs | 150 ++++++++++++++++++ .../src/services/transaction/tag/service.rs | 128 +++++++++++++++ 4 files changed, 368 insertions(+) create mode 100644 src-tauri/src/services/transaction/tag/dto.rs create mode 100644 src-tauri/src/services/transaction/tag/mod.rs create mode 100644 src-tauri/src/services/transaction/tag/repo.rs create mode 100644 src-tauri/src/services/transaction/tag/service.rs diff --git a/src-tauri/src/services/transaction/tag/dto.rs b/src-tauri/src/services/transaction/tag/dto.rs new file mode 100644 index 0000000..0fa8587 --- /dev/null +++ b/src-tauri/src/services/transaction/tag/dto.rs @@ -0,0 +1,84 @@ +use sea_orm::ActiveValue::Set; + +use crate::db::entities::tag::Model as TagModel; + +pub struct Tag { + pub id: i64, + pub name: String, + pub color_code: String, + pub created_at: String, + pub updated_at: String, +} + +impl From for Tag { + fn from(model: TagModel) -> Self { + Self { + id: model.id, + name: model.name, + color_code: model.color_code, + created_at: model.created_at.to_string(), + updated_at: model.updated_at.to_string(), + } + } +} + +pub struct CreateTagRequest { + pub name: String, + pub color_code: String, +} + +impl CreateTagRequest { + pub fn validate(&self) -> Result<(), String> { + if self.name.trim().is_empty() { + return Err("Tag name cannot be empty".to_string()); + } + if self.color_code.trim().is_empty() { + return Err("Color code cannot be empty".to_string()); + } + // Optionally, add more validation for color code format (e.g., hex code) + Ok(()) + } +} + +impl From for crate::db::entities::tag::ActiveModel { + fn from(request: CreateTagRequest) -> Self { + Self { + name: Set(request.name), + color_code: Set(request.color_code), + created_at: Set(chrono::Utc::now().to_rfc3339()), + updated_at: Set(chrono::Utc::now().to_rfc3339()), + ..Default::default() + } + } +} + +pub struct UpdateTagRequest { + pub name: Option, + pub color_code: Option, +} + +impl UpdateTagRequest { + pub fn validate(&self) -> Result<(), String> { + if let Some(name) = &self.name { + if name.trim().is_empty() { + return Err("Tag name cannot be empty".to_string()); + } + } + if let Some(color_code) = &self.color_code { + if color_code.trim().is_empty() { + return Err("Color code cannot be empty".to_string()); + } + // Optionally, add more validation for color code format (e.g., hex code) + } + Ok(()) + } + + pub fn apply_to(&self, tag: &mut crate::db::entities::tag::ActiveModel) { + if let Some(name) = &self.name { + tag.name = Set(name.clone()); + } + if let Some(color_code) = &self.color_code { + tag.color_code = Set(color_code.clone()); + } + } +} diff --git a/src-tauri/src/services/transaction/tag/mod.rs b/src-tauri/src/services/transaction/tag/mod.rs new file mode 100644 index 0000000..df05e03 --- /dev/null +++ b/src-tauri/src/services/transaction/tag/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/tag/repo.rs b/src-tauri/src/services/transaction/tag/repo.rs new file mode 100644 index 0000000..178aa2b --- /dev/null +++ b/src-tauri/src/services/transaction/tag/repo.rs @@ -0,0 +1,150 @@ +use crate::{ + db::{ + entities::{ + tag::{ActiveModel as TagActiveModel, Entity as TagEntity}, + transaction_tag::{ + ActiveModel as TransactionTagActiveModel, Column::TransactionId, + Entity as TransactionTagEntity, + }, + }, + Db, + }, + services::transaction::tag::{ + CreateTagRequest, Tag, TagServiceError, TagServiceResult, UpdateTagRequest, + }, +}; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, + QueryFilter, TransactionTrait, +}; + +type Result = TagServiceResult; + +#[async_trait::async_trait] +pub trait TagRepoService: Send + Sync + 'static { + async fn get_tags(&self) -> Result>; + async fn get_tag(&self, id: i64) -> Result>; + async fn create_tag(&self, request: CreateTagRequest) -> Result; + async fn create_tag_with_tx(&self, request: CreateTagRequest, tx: &Db) -> Result; + async fn update_tag(&self, id: i64, request: UpdateTagRequest) -> Result<()>; + async fn update_tag_with_tx(&self, id: i64, request: UpdateTagRequest, tx: &Db) -> Result<()>; + async fn delete_tag(&self, id: i64) -> Result<()>; + // + async fn update_tags_for_transaction( + &self, + transaction_id: i64, + tag_ids: Vec, + ) -> Result<()>; + async fn update_tags_for_transaction_with_tx( + &self, + transaction_id: i64, + new_tag_ids: Vec, + tx: &Db, + ) -> Result<()>; +} + +#[derive(Clone)] +pub struct TagRepoServiceImpl { + pub db: DatabaseConnection, +} + +#[async_trait::async_trait] +impl TagRepoService for TagRepoServiceImpl { + async fn get_tags(&self) -> Result> { + let tags = TagEntity::find().all(&self.db).await?; + Ok(tags.into_iter().map(|model| model.into()).collect()) + } + + async fn get_tag(&self, id: i64) -> Result> { + let tag = TagEntity::find_by_id(id).one(&self.db).await?; + Ok(tag.map(|model| model.into())) + } + + async fn create_tag(&self, request: CreateTagRequest) -> Result { + self.create_tag_with_tx(request, &(&self.db).into()).await + } + + async fn create_tag_with_tx(&self, request: CreateTagRequest, tx: &Db) -> Result { + request.validate().map_err(TagServiceError::Validation)?; + let new_tag: TagActiveModel = request.into(); + let res = new_tag.insert(tx).await?; + Ok(res.id) + } + + async fn update_tag(&self, id: i64, request: UpdateTagRequest) -> Result<()> { + self.update_tag_with_tx(id, request, &(&self.db).into()) + .await + } + + async fn update_tag_with_tx(&self, id: i64, request: UpdateTagRequest, tx: &Db) -> Result<()> { + let tag = TagEntity::find_by_id(id).one(tx).await?.ok_or_else(|| { + TagServiceError::TargetNotFound(format!("Tag with id {} not found", id)) + })?; + let mut tag: TagActiveModel = tag.into(); + + request.validate().map_err(TagServiceError::Validation)?; + request.apply_to(&mut tag); + tag.updated_at = Set(chrono::Utc::now().to_rfc3339()); + tag.update(tx).await?; + Ok(()) + } + + async fn delete_tag(&self, id: i64) -> Result<()> { + let tag = TagEntity::find_by_id(id) + .one(&self.db) + .await? + .ok_or_else(|| { + TagServiceError::TargetNotFound(format!("Tag with id {} not found", id)) + })?; + tag.delete(&self.db).await?; + Ok(()) + } + + async fn update_tags_for_transaction( + &self, + transaction_id: i64, + tag_ids: Vec, + ) -> Result<()> { + self.update_tags_for_transaction_with_tx(transaction_id, tag_ids, &(&self.db).into()) + .await + } + + async fn update_tags_for_transaction_with_tx( + &self, + transaction_id: i64, + new_tag_ids: Vec, + outer_tx: &Db, + ) -> Result<()> { + let inner_tx = match outer_tx { + Db::Connection(conn) => Some(conn.begin().await?), + Db::Transaction(_) => None, + }; + let tx = inner_tx.as_ref().map_or_else( + || match outer_tx { + Db::Connection(conn) => Db::Connection(conn), + Db::Transaction(tx) => Db::Transaction(tx), + }, + Db::Transaction, + ); + // delete existing tags for the transaction + TransactionTagEntity::delete_many() + .filter(TransactionId.eq(transaction_id)) + .exec(&tx) + .await?; + + // insert new tags for the transaction + let models = new_tag_ids + .iter() + .map(|tag_id| TransactionTagActiveModel { + transaction_id: Set(transaction_id), + tag_id: Set(*tag_id), + }) + .collect::>(); + TransactionTagEntity::insert_many(models).exec(&tx).await?; + + if let Some(inner_tx) = inner_tx { + inner_tx.commit().await?; + } + Ok(()) + } +} diff --git a/src-tauri/src/services/transaction/tag/service.rs b/src-tauri/src/services/transaction/tag/service.rs new file mode 100644 index 0000000..b00c743 --- /dev/null +++ b/src-tauri/src/services/transaction/tag/service.rs @@ -0,0 +1,128 @@ +use std::sync::Arc; + +use sea_orm::DatabaseConnection; +use thiserror::Error; + +use crate::{ + db::{Db, DbServiceError}, + services::transaction::tag::{ + repo::{TagRepoService, TagRepoServiceImpl}, + CreateTagRequest, Tag, UpdateTagRequest, + }, +}; + +#[derive(Error, Debug)] +pub enum TagServiceError { + #[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 TagServiceError { + fn from(err: sea_orm::DbErr) -> Self { + TagServiceError::DbErr(DbServiceError::from(err)) + } +} + +pub type TagServiceResult = std::result::Result; +type Result = TagServiceResult; + +#[async_trait::async_trait] +pub trait TagService: Send + Sync + 'static { + async fn get_tags(&self) -> Result>; + async fn get_tag(&self, id: i64) -> Result>; + async fn create_tag(&self, request: CreateTagRequest) -> Result; + async fn create_tag_with_tx(&self, request: CreateTagRequest, tx: &Db) -> Result; + async fn update_tag(&self, id: i64, request: UpdateTagRequest) -> Result<()>; + async fn update_tag_with_tx(&self, id: i64, request: UpdateTagRequest, tx: &Db) -> Result<()>; + async fn delete_tag(&self, id: i64) -> Result<()>; + // + async fn update_tags_for_transaction( + &self, + transaction_id: i64, + tag_ids: Vec, + ) -> Result<()>; + async fn update_tags_for_transaction_with_tx( + &self, + transaction_id: i64, + new_tag_ids: Vec, + tx: &Db, + ) -> Result<()>; +} + +pub struct TagServiceImpl +where + T: TagRepoService + ?Sized, +{ + db: DatabaseConnection, + tag_repo: Arc, +} + +impl TagServiceImpl { + pub fn new(db: DatabaseConnection) -> Self { + Self { + db: db.clone(), + tag_repo: Arc::new(TagRepoServiceImpl { db }), + } + } +} + +#[async_trait::async_trait] +impl TagService for TagServiceImpl +where + T: TagRepoService + ?Sized, +{ + async fn get_tags(&self) -> Result> { + self.tag_repo.get_tags().await + } + + async fn get_tag(&self, id: i64) -> Result> { + self.tag_repo.get_tag(id).await + } + + async fn create_tag(&self, request: CreateTagRequest) -> Result { + self.tag_repo.create_tag(request).await + } + + async fn create_tag_with_tx(&self, request: CreateTagRequest, tx: &Db) -> Result { + self.tag_repo.create_tag_with_tx(request, tx).await + } + + async fn update_tag(&self, id: i64, request: UpdateTagRequest) -> Result<()> { + self.tag_repo.update_tag(id, request).await + } + + async fn update_tag_with_tx(&self, id: i64, request: UpdateTagRequest, tx: &Db) -> Result<()> { + self.tag_repo.update_tag_with_tx(id, request, tx).await + } + + async fn delete_tag(&self, id: i64) -> Result<()> { + self.tag_repo.delete_tag(id).await + } + + async fn update_tags_for_transaction( + &self, + transaction_id: i64, + tag_ids: Vec, + ) -> Result<()> { + self.tag_repo + .update_tags_for_transaction(transaction_id, tag_ids) + .await + } + + async fn update_tags_for_transaction_with_tx( + &self, + transaction_id: i64, + new_tag_ids: Vec, + tx: &Db, + ) -> Result<()> { + self.tag_repo + .update_tags_for_transaction_with_tx(transaction_id, new_tag_ids, tx) + .await + } +}