diff --git a/core/src/domain/space.rs b/core/src/domain/space.rs index ca0651dbf..d0ae2f146 100644 --- a/core/src/domain/space.rs +++ b/core/src/domain/space.rs @@ -17,9 +17,6 @@ pub struct Space { /// Unique identifier pub id: Uuid, - /// Library this space belongs to - pub library_id: Uuid, - /// Human-friendly name (e.g., "All Devices", "Work Files") pub name: String, @@ -39,11 +36,10 @@ pub struct Space { impl Space { /// Create a new space - pub fn new(library_id: Uuid, name: String, icon: String, color: String) -> Self { + pub fn new(name: String, icon: String, color: String) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4(), - library_id, name, icon, color, @@ -59,9 +55,8 @@ impl Space { } /// Create a default "All Devices" space - pub fn create_default(library_id: Uuid) -> Self { + pub fn create_default() -> Self { Self::new( - library_id, "All Devices".to_string(), "Planet".to_string(), "#3B82F6".to_string(), @@ -283,15 +278,12 @@ mod tests { #[test] fn test_space_creation() { - let library_id = Uuid::new_v4(); let space = Space::new( - library_id, "Test Space".to_string(), "Folder".to_string(), "#FF0000".to_string(), ); - assert_eq!(space.library_id, library_id); assert_eq!(space.name, "Test Space"); assert_eq!(space.icon, "Folder"); assert_eq!(space.color, "#FF0000"); @@ -300,8 +292,7 @@ mod tests { #[test] fn test_default_space() { - let library_id = Uuid::new_v4(); - let space = Space::create_default(library_id); + let space = Space::create_default(); assert_eq!(space.name, "All Devices"); assert_eq!(space.icon, "Planet"); diff --git a/core/src/infra/db/entities/space.rs b/core/src/infra/db/entities/space.rs index a6a57e8b9..3cbfd7279 100644 --- a/core/src/infra/db/entities/space.rs +++ b/core/src/infra/db/entities/space.rs @@ -10,7 +10,6 @@ pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub uuid: Uuid, - pub library_id: i32, pub name: String, pub icon: String, pub color: String, @@ -50,7 +49,7 @@ impl Syncable for Model { } fn exclude_fields() -> Option<&'static [&'static str]> { - Some(&["id", "library_id"]) + Some(&["id"]) } fn sync_depends_on() -> &'static [&'static str] { diff --git a/core/src/infra/db/migration/m20250111_000001_create_spaces.rs b/core/src/infra/db/migration/m20250111_000001_create_spaces.rs index bb7bdb61f..7db35f15e 100644 --- a/core/src/infra/db/migration/m20250111_000001_create_spaces.rs +++ b/core/src/infra/db/migration/m20250111_000001_create_spaces.rs @@ -30,7 +30,6 @@ impl MigrationTrait for Migration { .not_null() .unique_key(), ) - .col(ColumnDef::new(Spaces::LibraryId).integer().not_null()) .col(ColumnDef::new(Spaces::Name).string().not_null()) .col(ColumnDef::new(Spaces::Icon).string().not_null()) .col(ColumnDef::new(Spaces::Color).string().not_null()) @@ -159,17 +158,7 @@ impl MigrationTrait for Migration { ) .await?; - // Create indexes for better query performance - manager - .create_index( - Index::create() - .name("idx_spaces_library_id") - .table(Spaces::Table) - .col(Spaces::LibraryId) - .to_owned(), - ) - .await?; - + // Create index for better query performance manager .create_index( Index::create() @@ -246,7 +235,6 @@ enum Spaces { Table, Id, Uuid, - LibraryId, Name, Icon, Color, diff --git a/core/src/library/manager.rs b/core/src/library/manager.rs index 8f95a7573..3cae6700d 100644 --- a/core/src/library/manager.rs +++ b/core/src/library/manager.rs @@ -293,6 +293,9 @@ impl LibraryManager { // Now open the library (which will call ensure_device_registered for current device) let library = self.open_library(&library_path, context).await?; + // Create default space with Quick Access group + self.create_default_space(&library).await?; + // Emit event self.event_bus.emit(Event::LibraryCreated { id: library.id(), @@ -354,6 +357,9 @@ impl LibraryManager { // Open the newly created library let library = self.open_library(&library_path, context.clone()).await?; + // Create default space with Quick Access group + self.create_default_space(&library).await?; + // Emit event self.event_bus.emit(Event::LibraryCreated { id: library.id(), @@ -940,6 +946,89 @@ impl LibraryManager { Ok(()) } + /// Create default space with Quick Access group for new libraries + async fn create_default_space(&self, library: &Arc) -> Result<()> { + use crate::domain::{GroupType, ItemType, Space, SpaceGroup, SpaceItem}; + use chrono::Utc; + use sea_orm::{ActiveModelTrait, Set}; + + let db = library.db().conn(); + + // Create default space + let space_id = uuid::Uuid::new_v4(); + let now = Utc::now(); + + let space_model = crate::infra::db::entities::space::ActiveModel { + id: Set(0), + uuid: Set(space_id), + name: Set("All Devices".to_string()), + icon: Set("Planet".to_string()), + color: Set("#3B82F6".to_string()), + order: Set(0), + created_at: Set(now.into()), + updated_at: Set(now.into()), + }; + + let space_result = space_model + .insert(db) + .await + .map_err(LibraryError::DatabaseError)?; + + info!("Created default space for library {}", library.id()); + + // Create Quick Access group + let group_id = uuid::Uuid::new_v4(); + + let group_type_json = serde_json::to_string(&GroupType::QuickAccess) + .map_err(|e| LibraryError::Other(format!("Failed to serialize group_type: {}", e)))?; + + let group_model = crate::infra::db::entities::space_group::ActiveModel { + id: Set(0), + uuid: Set(group_id), + space_id: Set(space_result.id), + name: Set("Quick Access".to_string()), + group_type: Set(group_type_json), + is_collapsed: Set(false), + order: Set(0), + created_at: Set(now.into()), + }; + + let group_result = group_model + .insert(db) + .await + .map_err(LibraryError::DatabaseError)?; + + // Create Quick Access items (Overview, Recents, Favorites) + let items = vec![ + (ItemType::Overview, 0), + (ItemType::Recents, 1), + (ItemType::Favorites, 2), + ]; + + for (item_type, order) in items { + let item_type_json = serde_json::to_string(&item_type) + .map_err(|e| LibraryError::Other(format!("Failed to serialize item_type: {}", e)))?; + + let item_model = crate::infra::db::entities::space_item::ActiveModel { + id: Set(0), + uuid: Set(uuid::Uuid::new_v4()), + group_id: Set(group_result.id), + item_type: Set(item_type_json), + order: Set(order), + created_at: Set(now.into()), + }; + + item_model + .insert(db) + .await + .map_err(LibraryError::DatabaseError)?; + } + + info!("Created default Quick Access group for library {}", library.id()); + + Ok(()) + } + /// Check if this device created the library (is the only device) async fn is_library_creator(&self, library: &Arc) -> Result { let db = library.db(); diff --git a/core/src/ops/mod.rs b/core/src/ops/mod.rs index f22266b32..a4fceef7f 100644 --- a/core/src/ops/mod.rs +++ b/core/src/ops/mod.rs @@ -23,6 +23,7 @@ pub mod metadata; pub mod network; pub mod search; pub mod sidecar; +pub mod spaces; pub mod sync; pub mod tags; pub mod volumes; diff --git a/core/src/ops/spaces/add_group/action.rs b/core/src/ops/spaces/add_group/action.rs new file mode 100644 index 000000000..d3a8c04f7 --- /dev/null +++ b/core/src/ops/spaces/add_group/action.rs @@ -0,0 +1,102 @@ +use super::{input::AddGroupInput, output::AddGroupOutput}; +use crate::{ + context::CoreContext, + domain::SpaceGroup, + infra::action::{ + error::{ActionError, ActionResult}, + LibraryAction, + }, +}; +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddGroupAction { + input: AddGroupInput, +} + +impl LibraryAction for AddGroupAction { + type Input = AddGroupInput; + type Output = AddGroupOutput; + + fn from_input(input: AddGroupInput) -> Result { + if input.name.trim().is_empty() { + return Err("Group name cannot be empty".to_string()); + } + + Ok(Self { input }) + } + + async fn execute( + self, + library: std::sync::Arc, + _context: std::sync::Arc, + ) -> Result { + let db = library.db().conn(); + + // Verify space exists + let space_model = crate::infra::db::entities::space::Entity::find() + .filter(crate::infra::db::entities::space::Column::Uuid.eq(self.input.space_id)) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .ok_or_else(|| ActionError::Internal(format!("Space {} not found", self.input.space_id)))?; + + // Get max order for this space + let max_order = crate::infra::db::entities::space_group::Entity::find() + .filter(crate::infra::db::entities::space_group::Column::SpaceId.eq(space_model.id)) + .order_by_desc(crate::infra::db::entities::space_group::Column::Order) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .map(|g| g.order) + .unwrap_or(-1); + + let group_id = uuid::Uuid::new_v4(); + let now = Utc::now(); + + // Serialize group_type to JSON + let group_type_json = serde_json::to_string(&self.input.group_type) + .map_err(|e| ActionError::Internal(format!("Failed to serialize group_type: {}", e)))?; + + let active_model = crate::infra::db::entities::space_group::ActiveModel { + id: Set(0), + uuid: Set(group_id), + space_id: Set(space_model.id), + name: Set(self.input.name.clone()), + group_type: Set(group_type_json), + is_collapsed: Set(false), + order: Set(max_order + 1), + created_at: Set(now.into()), + }; + + let result = active_model.insert(db).await.map_err(ActionError::SeaOrm)?; + + let group = SpaceGroup { + id: result.uuid, + space_id: self.input.space_id, + name: result.name, + group_type: self.input.group_type, + is_collapsed: result.is_collapsed, + order: result.order, + created_at: result.created_at.into(), + }; + + Ok(AddGroupOutput { group }) + } + + fn action_kind(&self) -> &'static str { + "spaces.add_group" + } + + async fn validate( + &self, + _library: std::sync::Arc, + _context: std::sync::Arc, + ) -> ActionResult<()> { + Ok(()) + } +} + +crate::register_library_action!(AddGroupAction, "spaces.add_group"); diff --git a/core/src/ops/spaces/add_group/input.rs b/core/src/ops/spaces/add_group/input.rs new file mode 100644 index 000000000..ecee88e16 --- /dev/null +++ b/core/src/ops/spaces/add_group/input.rs @@ -0,0 +1,11 @@ +use crate::domain::GroupType; +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct AddGroupInput { + pub space_id: Uuid, + pub name: String, + pub group_type: GroupType, +} diff --git a/core/src/ops/spaces/add_group/mod.rs b/core/src/ops/spaces/add_group/mod.rs new file mode 100644 index 000000000..3ae2ab355 --- /dev/null +++ b/core/src/ops/spaces/add_group/mod.rs @@ -0,0 +1,7 @@ +pub mod action; +pub mod input; +pub mod output; + +pub use action::*; +pub use input::*; +pub use output::*; diff --git a/core/src/ops/spaces/add_group/output.rs b/core/src/ops/spaces/add_group/output.rs new file mode 100644 index 000000000..60ff3623c --- /dev/null +++ b/core/src/ops/spaces/add_group/output.rs @@ -0,0 +1,8 @@ +use crate::domain::SpaceGroup; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct AddGroupOutput { + pub group: SpaceGroup, +} diff --git a/core/src/ops/spaces/add_item/action.rs b/core/src/ops/spaces/add_item/action.rs new file mode 100644 index 000000000..bdc8b0a0a --- /dev/null +++ b/core/src/ops/spaces/add_item/action.rs @@ -0,0 +1,94 @@ +use super::{input::AddItemInput, output::AddItemOutput}; +use crate::{ + context::CoreContext, + domain::SpaceItem, + infra::action::{ + error::{ActionError, ActionResult}, + LibraryAction, + }, +}; +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddItemAction { + input: AddItemInput, +} + +impl LibraryAction for AddItemAction { + type Input = AddItemInput; + type Output = AddItemOutput; + + fn from_input(input: AddItemInput) -> Result { + Ok(Self { input }) + } + + async fn execute( + self, + library: std::sync::Arc, + _context: std::sync::Arc, + ) -> Result { + let db = library.db().conn(); + + // Verify group exists + let group_model = crate::infra::db::entities::space_group::Entity::find() + .filter(crate::infra::db::entities::space_group::Column::Uuid.eq(self.input.group_id)) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .ok_or_else(|| ActionError::Internal(format!("Group {} not found", self.input.group_id)))?; + + // Get max order for this group + let max_order = crate::infra::db::entities::space_item::Entity::find() + .filter(crate::infra::db::entities::space_item::Column::GroupId.eq(group_model.id)) + .order_by_desc(crate::infra::db::entities::space_item::Column::Order) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .map(|i| i.order) + .unwrap_or(-1); + + let item_id = uuid::Uuid::new_v4(); + let now = Utc::now(); + + // Serialize item_type to JSON + let item_type_json = serde_json::to_string(&self.input.item_type) + .map_err(|e| ActionError::Internal(format!("Failed to serialize item_type: {}", e)))?; + + let active_model = crate::infra::db::entities::space_item::ActiveModel { + id: Set(0), + uuid: Set(item_id), + group_id: Set(group_model.id), + item_type: Set(item_type_json), + order: Set(max_order + 1), + created_at: Set(now.into()), + }; + + let result = active_model.insert(db).await.map_err(ActionError::SeaOrm)?; + + let item = SpaceItem { + id: result.uuid, + group_id: self.input.group_id, + item_type: self.input.item_type, + order: result.order, + created_at: result.created_at.into(), + }; + + Ok(AddItemOutput { item }) + } + + fn action_kind(&self) -> &'static str { + "spaces.add_item" + } + + async fn validate( + &self, + _library: std::sync::Arc, + _context: std::sync::Arc, + ) -> ActionResult<()> { + Ok(()) + } +} + +crate::register_library_action!(AddItemAction, "spaces.add_item"); diff --git a/core/src/ops/spaces/add_item/input.rs b/core/src/ops/spaces/add_item/input.rs new file mode 100644 index 000000000..5c140a96f --- /dev/null +++ b/core/src/ops/spaces/add_item/input.rs @@ -0,0 +1,10 @@ +use crate::domain::ItemType; +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct AddItemInput { + pub group_id: Uuid, + pub item_type: ItemType, +} diff --git a/core/src/ops/spaces/add_item/mod.rs b/core/src/ops/spaces/add_item/mod.rs new file mode 100644 index 000000000..3ae2ab355 --- /dev/null +++ b/core/src/ops/spaces/add_item/mod.rs @@ -0,0 +1,7 @@ +pub mod action; +pub mod input; +pub mod output; + +pub use action::*; +pub use input::*; +pub use output::*; diff --git a/core/src/ops/spaces/add_item/output.rs b/core/src/ops/spaces/add_item/output.rs new file mode 100644 index 000000000..25b06f3ff --- /dev/null +++ b/core/src/ops/spaces/add_item/output.rs @@ -0,0 +1,8 @@ +use crate::domain::SpaceItem; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct AddItemOutput { + pub item: SpaceItem, +} diff --git a/core/src/ops/spaces/create/action.rs b/core/src/ops/spaces/create/action.rs new file mode 100644 index 000000000..a90b543d2 --- /dev/null +++ b/core/src/ops/spaces/create/action.rs @@ -0,0 +1,101 @@ +use super::{input::SpaceCreateInput, output::SpaceCreateOutput}; +use crate::{ + context::CoreContext, + domain::Space, + infra::action::{ + error::{ActionError, ActionResult}, + LibraryAction, + }, +}; +use chrono::Utc; +use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpaceCreateAction { + input: SpaceCreateInput, +} + +impl SpaceCreateAction { + pub fn new(input: SpaceCreateInput) -> Self { + Self { input } + } +} + +impl LibraryAction for SpaceCreateAction { + type Input = SpaceCreateInput; + type Output = SpaceCreateOutput; + + fn from_input(input: SpaceCreateInput) -> Result { + // Validate input + if input.name.trim().is_empty() { + return Err("Space name cannot be empty".to_string()); + } + + if !Space::validate_color(&input.color) { + return Err("Invalid color format. Must be #RRGGBB".to_string()); + } + + Ok(SpaceCreateAction::new(input)) + } + + async fn execute( + self, + library: std::sync::Arc, + _context: std::sync::Arc, + ) -> Result { + let db = library.db().conn(); + + // Get the current max order + let max_order = crate::infra::db::entities::space::Entity::find() + .order_by_desc(crate::infra::db::entities::space::Column::Order) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .map(|s| s.order) + .unwrap_or(-1); + + let space_id = uuid::Uuid::new_v4(); + let now = Utc::now(); + + // Create space entity + let active_model = crate::infra::db::entities::space::ActiveModel { + id: Set(0), // Auto-increment + uuid: Set(space_id), + name: Set(self.input.name.clone()), + icon: Set(self.input.icon.clone()), + color: Set(self.input.color.clone()), + order: Set(max_order + 1), + created_at: Set(now.into()), + updated_at: Set(now.into()), + }; + + let result = active_model.insert(db).await.map_err(ActionError::SeaOrm)?; + + let space = Space { + id: result.uuid, + name: result.name, + icon: result.icon, + color: result.color, + order: result.order, + created_at: result.created_at.into(), + updated_at: result.updated_at.into(), + }; + + Ok(SpaceCreateOutput { space }) + } + + fn action_kind(&self) -> &'static str { + "spaces.create" + } + + async fn validate( + &self, + _library: std::sync::Arc, + _context: std::sync::Arc, + ) -> ActionResult<()> { + Ok(()) + } +} + +crate::register_library_action!(SpaceCreateAction, "spaces.create"); diff --git a/core/src/ops/spaces/create/input.rs b/core/src/ops/spaces/create/input.rs new file mode 100644 index 000000000..68e50f04c --- /dev/null +++ b/core/src/ops/spaces/create/input.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpaceCreateInput { + pub name: String, + pub icon: String, + pub color: String, +} diff --git a/core/src/ops/spaces/create/mod.rs b/core/src/ops/spaces/create/mod.rs new file mode 100644 index 000000000..3ae2ab355 --- /dev/null +++ b/core/src/ops/spaces/create/mod.rs @@ -0,0 +1,7 @@ +pub mod action; +pub mod input; +pub mod output; + +pub use action::*; +pub use input::*; +pub use output::*; diff --git a/core/src/ops/spaces/create/output.rs b/core/src/ops/spaces/create/output.rs new file mode 100644 index 000000000..ceaf18d33 --- /dev/null +++ b/core/src/ops/spaces/create/output.rs @@ -0,0 +1,8 @@ +use crate::domain::Space; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpaceCreateOutput { + pub space: Space, +} diff --git a/core/src/ops/spaces/delete/action.rs b/core/src/ops/spaces/delete/action.rs new file mode 100644 index 000000000..16076c7cd --- /dev/null +++ b/core/src/ops/spaces/delete/action.rs @@ -0,0 +1,58 @@ +use super::{input::SpaceDeleteInput, output::SpaceDeleteOutput}; +use crate::{ + context::CoreContext, + infra::action::{ + error::{ActionError, ActionResult}, + LibraryAction, + }, +}; +use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpaceDeleteAction { + input: SpaceDeleteInput, +} + +impl LibraryAction for SpaceDeleteAction { + type Input = SpaceDeleteInput; + type Output = SpaceDeleteOutput; + + fn from_input(input: SpaceDeleteInput) -> Result { + Ok(Self { input }) + } + + async fn execute( + self, + library: std::sync::Arc, + _context: std::sync::Arc, + ) -> Result { + let db = library.db().conn(); + + let space_model = crate::infra::db::entities::space::Entity::find() + .filter(crate::infra::db::entities::space::Column::Uuid.eq(self.input.space_id)) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .ok_or_else(|| ActionError::Internal(format!("Space {} not found", self.input.space_id)))?; + + // Delete will cascade to groups and items due to foreign key constraints + space_model.delete(db).await.map_err(ActionError::SeaOrm)?; + + Ok(SpaceDeleteOutput { success: true }) + } + + fn action_kind(&self) -> &'static str { + "spaces.delete" + } + + async fn validate( + &self, + _library: std::sync::Arc, + _context: std::sync::Arc, + ) -> ActionResult<()> { + Ok(()) + } +} + +crate::register_library_action!(SpaceDeleteAction, "spaces.delete"); diff --git a/core/src/ops/spaces/delete/input.rs b/core/src/ops/spaces/delete/input.rs new file mode 100644 index 000000000..2a682b19e --- /dev/null +++ b/core/src/ops/spaces/delete/input.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpaceDeleteInput { + pub space_id: Uuid, +} diff --git a/core/src/ops/spaces/delete/mod.rs b/core/src/ops/spaces/delete/mod.rs new file mode 100644 index 000000000..3ae2ab355 --- /dev/null +++ b/core/src/ops/spaces/delete/mod.rs @@ -0,0 +1,7 @@ +pub mod action; +pub mod input; +pub mod output; + +pub use action::*; +pub use input::*; +pub use output::*; diff --git a/core/src/ops/spaces/delete/output.rs b/core/src/ops/spaces/delete/output.rs new file mode 100644 index 000000000..2b68da3d6 --- /dev/null +++ b/core/src/ops/spaces/delete/output.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpaceDeleteOutput { + pub success: bool, +} diff --git a/core/src/ops/spaces/delete_group/action.rs b/core/src/ops/spaces/delete_group/action.rs new file mode 100644 index 000000000..eaddf1fa4 --- /dev/null +++ b/core/src/ops/spaces/delete_group/action.rs @@ -0,0 +1,58 @@ +use super::{input::DeleteGroupInput, output::DeleteGroupOutput}; +use crate::{ + context::CoreContext, + infra::action::{ + error::{ActionError, ActionResult}, + LibraryAction, + }, +}; +use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteGroupAction { + input: DeleteGroupInput, +} + +impl LibraryAction for DeleteGroupAction { + type Input = DeleteGroupInput; + type Output = DeleteGroupOutput; + + fn from_input(input: DeleteGroupInput) -> Result { + Ok(Self { input }) + } + + async fn execute( + self, + library: std::sync::Arc, + _context: std::sync::Arc, + ) -> Result { + let db = library.db().conn(); + + let group_model = crate::infra::db::entities::space_group::Entity::find() + .filter(crate::infra::db::entities::space_group::Column::Uuid.eq(self.input.group_id)) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .ok_or_else(|| ActionError::Internal(format!("Group {} not found", self.input.group_id)))?; + + // Delete will cascade to items due to foreign key constraints + group_model.delete(db).await.map_err(ActionError::SeaOrm)?; + + Ok(DeleteGroupOutput { success: true }) + } + + fn action_kind(&self) -> &'static str { + "spaces.delete_group" + } + + async fn validate( + &self, + _library: std::sync::Arc, + _context: std::sync::Arc, + ) -> ActionResult<()> { + Ok(()) + } +} + +crate::register_library_action!(DeleteGroupAction, "spaces.delete_group"); diff --git a/core/src/ops/spaces/delete_group/input.rs b/core/src/ops/spaces/delete_group/input.rs new file mode 100644 index 000000000..75bc213d1 --- /dev/null +++ b/core/src/ops/spaces/delete_group/input.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct DeleteGroupInput { + pub group_id: Uuid, +} diff --git a/core/src/ops/spaces/delete_group/mod.rs b/core/src/ops/spaces/delete_group/mod.rs new file mode 100644 index 000000000..3ae2ab355 --- /dev/null +++ b/core/src/ops/spaces/delete_group/mod.rs @@ -0,0 +1,7 @@ +pub mod action; +pub mod input; +pub mod output; + +pub use action::*; +pub use input::*; +pub use output::*; diff --git a/core/src/ops/spaces/delete_group/output.rs b/core/src/ops/spaces/delete_group/output.rs new file mode 100644 index 000000000..086772479 --- /dev/null +++ b/core/src/ops/spaces/delete_group/output.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct DeleteGroupOutput { + pub success: bool, +} diff --git a/core/src/ops/spaces/delete_item/action.rs b/core/src/ops/spaces/delete_item/action.rs new file mode 100644 index 000000000..ed334bbb7 --- /dev/null +++ b/core/src/ops/spaces/delete_item/action.rs @@ -0,0 +1,57 @@ +use super::{input::DeleteItemInput, output::DeleteItemOutput}; +use crate::{ + context::CoreContext, + infra::action::{ + error::{ActionError, ActionResult}, + LibraryAction, + }, +}; +use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteItemAction { + input: DeleteItemInput, +} + +impl LibraryAction for DeleteItemAction { + type Input = DeleteItemInput; + type Output = DeleteItemOutput; + + fn from_input(input: DeleteItemInput) -> Result { + Ok(Self { input }) + } + + async fn execute( + self, + library: std::sync::Arc, + _context: std::sync::Arc, + ) -> Result { + let db = library.db().conn(); + + let item_model = crate::infra::db::entities::space_item::Entity::find() + .filter(crate::infra::db::entities::space_item::Column::Uuid.eq(self.input.item_id)) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .ok_or_else(|| ActionError::Internal(format!("Item {} not found", self.input.item_id)))?; + + item_model.delete(db).await.map_err(ActionError::SeaOrm)?; + + Ok(DeleteItemOutput { success: true }) + } + + fn action_kind(&self) -> &'static str { + "spaces.delete_item" + } + + async fn validate( + &self, + _library: std::sync::Arc, + _context: std::sync::Arc, + ) -> ActionResult<()> { + Ok(()) + } +} + +crate::register_library_action!(DeleteItemAction, "spaces.delete_item"); diff --git a/core/src/ops/spaces/delete_item/input.rs b/core/src/ops/spaces/delete_item/input.rs new file mode 100644 index 000000000..7a8f10fc3 --- /dev/null +++ b/core/src/ops/spaces/delete_item/input.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct DeleteItemInput { + pub item_id: Uuid, +} diff --git a/core/src/ops/spaces/delete_item/mod.rs b/core/src/ops/spaces/delete_item/mod.rs new file mode 100644 index 000000000..3ae2ab355 --- /dev/null +++ b/core/src/ops/spaces/delete_item/mod.rs @@ -0,0 +1,7 @@ +pub mod action; +pub mod input; +pub mod output; + +pub use action::*; +pub use input::*; +pub use output::*; diff --git a/core/src/ops/spaces/delete_item/output.rs b/core/src/ops/spaces/delete_item/output.rs new file mode 100644 index 000000000..cae4648b5 --- /dev/null +++ b/core/src/ops/spaces/delete_item/output.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct DeleteItemOutput { + pub success: bool, +} diff --git a/core/src/ops/spaces/get/mod.rs b/core/src/ops/spaces/get/mod.rs new file mode 100644 index 000000000..6d28ca360 --- /dev/null +++ b/core/src/ops/spaces/get/mod.rs @@ -0,0 +1,5 @@ +pub mod output; +pub mod query; + +pub use output::*; +pub use query::*; diff --git a/core/src/ops/spaces/get/output.rs b/core/src/ops/spaces/get/output.rs new file mode 100644 index 000000000..5aad1651c --- /dev/null +++ b/core/src/ops/spaces/get/output.rs @@ -0,0 +1,8 @@ +use crate::domain::Space; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpaceGetOutput { + pub space: Space, +} diff --git a/core/src/ops/spaces/get/query.rs b/core/src/ops/spaces/get/query.rs new file mode 100644 index 000000000..2c40aab5b --- /dev/null +++ b/core/src/ops/spaces/get/query.rs @@ -0,0 +1,69 @@ +use super::output::SpaceGetOutput; +use crate::domain::Space; +use crate::infra::query::{QueryError, QueryResult}; +use crate::{context::CoreContext, infra::query::LibraryQuery}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpaceGetQueryInput { + pub space_id: Uuid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpaceGetQuery { + space_id: Uuid, +} + +impl LibraryQuery for SpaceGetQuery { + type Input = SpaceGetQueryInput; + type Output = SpaceGetOutput; + + fn from_input(input: Self::Input) -> QueryResult { + Ok(Self { + space_id: input.space_id, + }) + } + + async fn execute( + self, + context: Arc, + session: crate::infra::api::SessionContext, + ) -> QueryResult { + let library_id = session + .current_library_id + .ok_or_else(|| QueryError::Internal("No library selected".to_string()))?; + + let library = context + .libraries() + .await + .get_library(library_id) + .await + .ok_or_else(|| QueryError::Internal("Library not found".to_string()))?; + + let db = library.db().conn(); + + let space_model = crate::infra::db::entities::space::Entity::find() + .filter(crate::infra::db::entities::space::Column::Uuid.eq(self.space_id)) + .one(db) + .await? + .ok_or_else(|| QueryError::Internal(format!("Space {} not found", self.space_id)))?; + + let space = Space { + id: space_model.uuid, + name: space_model.name, + icon: space_model.icon, + color: space_model.color, + order: space_model.order, + created_at: space_model.created_at.into(), + updated_at: space_model.updated_at.into(), + }; + + Ok(SpaceGetOutput { space }) + } +} + +crate::register_library_query!(SpaceGetQuery, "spaces.get"); diff --git a/core/src/ops/spaces/get_layout/mod.rs b/core/src/ops/spaces/get_layout/mod.rs new file mode 100644 index 000000000..6d28ca360 --- /dev/null +++ b/core/src/ops/spaces/get_layout/mod.rs @@ -0,0 +1,5 @@ +pub mod output; +pub mod query; + +pub use output::*; +pub use query::*; diff --git a/core/src/ops/spaces/get_layout/output.rs b/core/src/ops/spaces/get_layout/output.rs new file mode 100644 index 000000000..416f9c760 --- /dev/null +++ b/core/src/ops/spaces/get_layout/output.rs @@ -0,0 +1,8 @@ +use crate::domain::{SpaceLayout}; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpaceLayoutOutput { + pub layout: SpaceLayout, +} diff --git a/core/src/ops/spaces/get_layout/query.rs b/core/src/ops/spaces/get_layout/query.rs new file mode 100644 index 000000000..ad419b419 --- /dev/null +++ b/core/src/ops/spaces/get_layout/query.rs @@ -0,0 +1,122 @@ +use super::output::SpaceLayoutOutput; +use crate::domain::{GroupType, ItemType, Space, SpaceGroup, SpaceGroupWithItems, SpaceItem, SpaceLayout}; +use crate::infra::query::{QueryError, QueryResult}; +use crate::{context::CoreContext, infra::query::LibraryQuery}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpaceLayoutQueryInput { + pub space_id: Uuid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpaceLayoutQuery { + space_id: Uuid, +} + +impl LibraryQuery for SpaceLayoutQuery { + type Input = SpaceLayoutQueryInput; + type Output = SpaceLayoutOutput; + + fn from_input(input: Self::Input) -> QueryResult { + Ok(Self { + space_id: input.space_id, + }) + } + + async fn execute( + self, + context: Arc, + session: crate::infra::api::SessionContext, + ) -> QueryResult { + let library_id = session + .current_library_id + .ok_or_else(|| QueryError::Internal("No library selected".to_string()))?; + + let library = context + .libraries() + .await + .get_library(library_id) + .await + .ok_or_else(|| QueryError::Internal("Library not found".to_string()))?; + + let db = library.db().conn(); + + // Get space + let space_model = crate::infra::db::entities::space::Entity::find() + .filter(crate::infra::db::entities::space::Column::Uuid.eq(self.space_id)) + .one(db) + .await? + .ok_or_else(|| QueryError::Internal(format!("Space {} not found", self.space_id)))?; + + let space = Space { + id: space_model.uuid, + name: space_model.name, + icon: space_model.icon, + color: space_model.color, + order: space_model.order, + created_at: space_model.created_at.into(), + updated_at: space_model.updated_at.into(), + }; + + // Get groups for this space + let group_models = crate::infra::db::entities::space_group::Entity::find() + .filter(crate::infra::db::entities::space_group::Column::SpaceId.eq(space_model.id)) + .order_by_asc(crate::infra::db::entities::space_group::Column::Order) + .all(db) + .await?; + + let mut groups = Vec::new(); + + for group_model in group_models { + // Parse group_type JSON + let group_type: GroupType = serde_json::from_str(&group_model.group_type) + .map_err(|e| QueryError::Internal(format!("Failed to parse group_type: {}", e)))?; + + let group = SpaceGroup { + id: group_model.uuid, + space_id: self.space_id, + name: group_model.name, + group_type, + is_collapsed: group_model.is_collapsed, + order: group_model.order, + created_at: group_model.created_at.into(), + }; + + // Get items for this group + let item_models = crate::infra::db::entities::space_item::Entity::find() + .filter(crate::infra::db::entities::space_item::Column::GroupId.eq(group_model.id)) + .order_by_asc(crate::infra::db::entities::space_item::Column::Order) + .all(db) + .await?; + + let mut items = Vec::new(); + + for item_model in item_models { + // Parse item_type JSON + let item_type: ItemType = serde_json::from_str(&item_model.item_type) + .map_err(|e| QueryError::Internal(format!("Failed to parse item_type: {}", e)))?; + + items.push(SpaceItem { + id: item_model.uuid, + group_id: group_model.uuid, + item_type, + order: item_model.order, + created_at: item_model.created_at.into(), + }); + } + + groups.push(SpaceGroupWithItems { group, items }); + } + + let layout = SpaceLayout { space, groups }; + + Ok(SpaceLayoutOutput { layout }) + } +} + +crate::register_library_query!(SpaceLayoutQuery, "spaces.get_layout"); diff --git a/core/src/ops/spaces/list/mod.rs b/core/src/ops/spaces/list/mod.rs new file mode 100644 index 000000000..6d28ca360 --- /dev/null +++ b/core/src/ops/spaces/list/mod.rs @@ -0,0 +1,5 @@ +pub mod output; +pub mod query; + +pub use output::*; +pub use query::*; diff --git a/core/src/ops/spaces/list/output.rs b/core/src/ops/spaces/list/output.rs new file mode 100644 index 000000000..58e2a848e --- /dev/null +++ b/core/src/ops/spaces/list/output.rs @@ -0,0 +1,8 @@ +use crate::domain::{Space}; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpacesListOutput { + pub spaces: Vec, +} diff --git a/core/src/ops/spaces/list/query.rs b/core/src/ops/spaces/list/query.rs new file mode 100644 index 000000000..623da9c5b --- /dev/null +++ b/core/src/ops/spaces/list/query.rs @@ -0,0 +1,64 @@ +use super::output::SpacesListOutput; +use crate::domain::Space; +use crate::infra::query::{QueryError, QueryResult}; +use crate::{context::CoreContext, infra::query::LibraryQuery}; +use sea_orm::{EntityTrait, QueryOrder}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::sync::Arc; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpacesListQueryInput; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpacesListQuery; + +impl LibraryQuery for SpacesListQuery { + type Input = SpacesListQueryInput; + type Output = SpacesListOutput; + + fn from_input(_input: Self::Input) -> QueryResult { + Ok(Self {}) + } + + async fn execute( + self, + context: Arc, + session: crate::infra::api::SessionContext, + ) -> QueryResult { + let library_id = session + .current_library_id + .ok_or_else(|| QueryError::Internal("No library selected".to_string()))?; + + let library = context + .libraries() + .await + .get_library(library_id) + .await + .ok_or_else(|| QueryError::Internal("Library not found".to_string()))?; + + let db = library.db().conn(); + + let space_models = crate::infra::db::entities::space::Entity::find() + .order_by_asc(crate::infra::db::entities::space::Column::Order) + .all(db) + .await?; + + let spaces = space_models + .into_iter() + .map(|model| Space { + id: model.uuid, + name: model.name, + icon: model.icon, + color: model.color, + order: model.order, + created_at: model.created_at.into(), + updated_at: model.updated_at.into(), + }) + .collect(); + + Ok(SpacesListOutput { spaces }) + } +} + +crate::register_library_query!(SpacesListQuery, "spaces.list"); diff --git a/core/src/ops/spaces/mod.rs b/core/src/ops/spaces/mod.rs new file mode 100644 index 000000000..b3d44c22a --- /dev/null +++ b/core/src/ops/spaces/mod.rs @@ -0,0 +1,29 @@ +//! Space operations +//! +//! Queries and actions for managing Spaces, SpaceGroups, and SpaceItems + +pub mod add_group; +pub mod add_item; +pub mod create; +pub mod delete; +pub mod delete_group; +pub mod delete_item; +pub mod get; +pub mod get_layout; +pub mod list; +pub mod reorder; +pub mod update; +pub mod update_group; + +pub use add_group::*; +pub use add_item::*; +pub use create::*; +pub use delete::*; +pub use delete_group::*; +pub use delete_item::*; +pub use get::*; +pub use get_layout::*; +pub use list::*; +pub use reorder::*; +pub use update::*; +pub use update_group::*; diff --git a/core/src/ops/spaces/reorder/action.rs b/core/src/ops/spaces/reorder/action.rs new file mode 100644 index 000000000..7e87e1e5b --- /dev/null +++ b/core/src/ops/spaces/reorder/action.rs @@ -0,0 +1,118 @@ +use super::{ + input::{ReorderGroupsInput, ReorderItemsInput}, + output::ReorderOutput, +}; +use crate::{ + context::CoreContext, + infra::action::{ + error::{ActionError, ActionResult}, + LibraryAction, + }, +}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReorderGroupsAction { + input: ReorderGroupsInput, +} + +impl LibraryAction for ReorderGroupsAction { + type Input = ReorderGroupsInput; + type Output = ReorderOutput; + + fn from_input(input: ReorderGroupsInput) -> Result { + Ok(Self { input }) + } + + async fn execute( + self, + library: std::sync::Arc, + _context: std::sync::Arc, + ) -> Result { + let db = library.db().conn(); + + // Update order for each group + for (index, group_id) in self.input.group_ids.iter().enumerate() { + let group_model = crate::infra::db::entities::space_group::Entity::find() + .filter(crate::infra::db::entities::space_group::Column::Uuid.eq(*group_id)) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .ok_or_else(|| ActionError::Internal(format!("Group {} not found", group_id)))?; + + let mut active_model: crate::infra::db::entities::space_group::ActiveModel = + group_model.into(); + active_model.order = Set(index as i32); + active_model.update(db).await.map_err(ActionError::SeaOrm)?; + } + + Ok(ReorderOutput { success: true }) + } + + fn action_kind(&self) -> &'static str { + "spaces.reorder_groups" + } + + async fn validate( + &self, + _library: std::sync::Arc, + _context: std::sync::Arc, + ) -> ActionResult<()> { + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReorderItemsAction { + input: ReorderItemsInput, +} + +impl LibraryAction for ReorderItemsAction { + type Input = ReorderItemsInput; + type Output = ReorderOutput; + + fn from_input(input: ReorderItemsInput) -> Result { + Ok(Self { input }) + } + + async fn execute( + self, + library: std::sync::Arc, + _context: std::sync::Arc, + ) -> Result { + let db = library.db().conn(); + + // Update order for each item + for (index, item_id) in self.input.item_ids.iter().enumerate() { + let item_model = crate::infra::db::entities::space_item::Entity::find() + .filter(crate::infra::db::entities::space_item::Column::Uuid.eq(*item_id)) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .ok_or_else(|| ActionError::Internal(format!("Item {} not found", item_id)))?; + + let mut active_model: crate::infra::db::entities::space_item::ActiveModel = + item_model.into(); + active_model.order = Set(index as i32); + active_model.update(db).await.map_err(ActionError::SeaOrm)?; + } + + Ok(ReorderOutput { success: true }) + } + + fn action_kind(&self) -> &'static str { + "spaces.reorder_items" + } + + async fn validate( + &self, + _library: std::sync::Arc, + _context: std::sync::Arc, + ) -> ActionResult<()> { + Ok(()) + } +} + +crate::register_library_action!(ReorderGroupsAction, "spaces.reorder_groups"); +crate::register_library_action!(ReorderItemsAction, "spaces.reorder_items"); diff --git a/core/src/ops/spaces/reorder/input.rs b/core/src/ops/spaces/reorder/input.rs new file mode 100644 index 000000000..b6ca9aa2d --- /dev/null +++ b/core/src/ops/spaces/reorder/input.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct ReorderGroupsInput { + pub space_id: Uuid, + pub group_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct ReorderItemsInput { + pub group_id: Uuid, + pub item_ids: Vec, +} diff --git a/core/src/ops/spaces/reorder/mod.rs b/core/src/ops/spaces/reorder/mod.rs new file mode 100644 index 000000000..3ae2ab355 --- /dev/null +++ b/core/src/ops/spaces/reorder/mod.rs @@ -0,0 +1,7 @@ +pub mod action; +pub mod input; +pub mod output; + +pub use action::*; +pub use input::*; +pub use output::*; diff --git a/core/src/ops/spaces/reorder/output.rs b/core/src/ops/spaces/reorder/output.rs new file mode 100644 index 000000000..e55c2cc7b --- /dev/null +++ b/core/src/ops/spaces/reorder/output.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct ReorderOutput { + pub success: bool, +} diff --git a/core/src/ops/spaces/update/action.rs b/core/src/ops/spaces/update/action.rs new file mode 100644 index 000000000..11aad4a9a --- /dev/null +++ b/core/src/ops/spaces/update/action.rs @@ -0,0 +1,97 @@ +use super::{input::SpaceUpdateInput, output::SpaceUpdateOutput}; +use crate::{ + context::CoreContext, + domain::Space, + infra::action::{ + error::{ActionError, ActionResult}, + LibraryAction, + }, +}; +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpaceUpdateAction { + input: SpaceUpdateInput, +} + +impl LibraryAction for SpaceUpdateAction { + type Input = SpaceUpdateInput; + type Output = SpaceUpdateOutput; + + fn from_input(input: SpaceUpdateInput) -> Result { + if let Some(ref name) = input.name { + if name.trim().is_empty() { + return Err("Space name cannot be empty".to_string()); + } + } + + if let Some(ref color) = input.color { + if !Space::validate_color(color) { + return Err("Invalid color format. Must be #RRGGBB".to_string()); + } + } + + Ok(Self { input }) + } + + async fn execute( + self, + library: std::sync::Arc, + _context: std::sync::Arc, + ) -> Result { + let db = library.db().conn(); + + let space_model = crate::infra::db::entities::space::Entity::find() + .filter(crate::infra::db::entities::space::Column::Uuid.eq(self.input.space_id)) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .ok_or_else(|| ActionError::Internal(format!("Space {} not found", self.input.space_id)))?; + + let mut active_model: crate::infra::db::entities::space::ActiveModel = space_model.into(); + + if let Some(name) = self.input.name { + active_model.name = Set(name); + } + + if let Some(icon) = self.input.icon { + active_model.icon = Set(icon); + } + + if let Some(color) = self.input.color { + active_model.color = Set(color); + } + + active_model.updated_at = Set(Utc::now().into()); + + let result = active_model.update(db).await.map_err(ActionError::SeaOrm)?; + + let space = Space { + id: result.uuid, + name: result.name, + icon: result.icon, + color: result.color, + order: result.order, + created_at: result.created_at.into(), + updated_at: result.updated_at.into(), + }; + + Ok(SpaceUpdateOutput { space }) + } + + fn action_kind(&self) -> &'static str { + "spaces.update" + } + + async fn validate( + &self, + _library: std::sync::Arc, + _context: std::sync::Arc, + ) -> ActionResult<()> { + Ok(()) + } +} + +crate::register_library_action!(SpaceUpdateAction, "spaces.update"); diff --git a/core/src/ops/spaces/update/input.rs b/core/src/ops/spaces/update/input.rs new file mode 100644 index 000000000..670fc4bf7 --- /dev/null +++ b/core/src/ops/spaces/update/input.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpaceUpdateInput { + pub space_id: Uuid, + pub name: Option, + pub icon: Option, + pub color: Option, +} diff --git a/core/src/ops/spaces/update/mod.rs b/core/src/ops/spaces/update/mod.rs new file mode 100644 index 000000000..3ae2ab355 --- /dev/null +++ b/core/src/ops/spaces/update/mod.rs @@ -0,0 +1,7 @@ +pub mod action; +pub mod input; +pub mod output; + +pub use action::*; +pub use input::*; +pub use output::*; diff --git a/core/src/ops/spaces/update/output.rs b/core/src/ops/spaces/update/output.rs new file mode 100644 index 000000000..9b6a06a97 --- /dev/null +++ b/core/src/ops/spaces/update/output.rs @@ -0,0 +1,8 @@ +use crate::domain::Space; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpaceUpdateOutput { + pub space: Space, +} diff --git a/core/src/ops/spaces/update_group/action.rs b/core/src/ops/spaces/update_group/action.rs new file mode 100644 index 000000000..a26c4178d --- /dev/null +++ b/core/src/ops/spaces/update_group/action.rs @@ -0,0 +1,95 @@ +use super::{input::UpdateGroupInput, output::UpdateGroupOutput}; +use crate::{ + context::CoreContext, + domain::{GroupType, SpaceGroup}, + infra::action::{ + error::{ActionError, ActionResult}, + LibraryAction, + }, +}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateGroupAction { + input: UpdateGroupInput, +} + +impl LibraryAction for UpdateGroupAction { + type Input = UpdateGroupInput; + type Output = UpdateGroupOutput; + + fn from_input(input: UpdateGroupInput) -> Result { + if let Some(ref name) = input.name { + if name.trim().is_empty() { + return Err("Group name cannot be empty".to_string()); + } + } + + Ok(Self { input }) + } + + async fn execute( + self, + library: std::sync::Arc, + _context: std::sync::Arc, + ) -> Result { + let db = library.db().conn(); + + let group_model = crate::infra::db::entities::space_group::Entity::find() + .filter(crate::infra::db::entities::space_group::Column::Uuid.eq(self.input.group_id)) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .ok_or_else(|| ActionError::Internal(format!("Group {} not found", self.input.group_id)))?; + + let mut active_model: crate::infra::db::entities::space_group::ActiveModel = group_model.clone().into(); + + if let Some(name) = self.input.name { + active_model.name = Set(name); + } + + if let Some(is_collapsed) = self.input.is_collapsed { + active_model.is_collapsed = Set(is_collapsed); + } + + let result = active_model.update(db).await.map_err(ActionError::SeaOrm)?; + + // Parse group_type from JSON + let group_type: GroupType = serde_json::from_str(&result.group_type) + .map_err(|e| ActionError::Internal(format!("Failed to parse group_type: {}", e)))?; + + // Get space UUID + let space_model = crate::infra::db::entities::space::Entity::find_by_id(result.space_id) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .ok_or_else(|| ActionError::Internal("Space not found for group".to_string()))?; + + let group = SpaceGroup { + id: result.uuid, + space_id: space_model.uuid, + name: result.name, + group_type, + is_collapsed: result.is_collapsed, + order: result.order, + created_at: result.created_at.into(), + }; + + Ok(UpdateGroupOutput { group }) + } + + fn action_kind(&self) -> &'static str { + "spaces.update_group" + } + + async fn validate( + &self, + _library: std::sync::Arc, + _context: std::sync::Arc, + ) -> ActionResult<()> { + Ok(()) + } +} + +crate::register_library_action!(UpdateGroupAction, "spaces.update_group"); diff --git a/core/src/ops/spaces/update_group/input.rs b/core/src/ops/spaces/update_group/input.rs new file mode 100644 index 000000000..8afca9fee --- /dev/null +++ b/core/src/ops/spaces/update_group/input.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct UpdateGroupInput { + pub group_id: Uuid, + pub name: Option, + pub is_collapsed: Option, +} diff --git a/core/src/ops/spaces/update_group/mod.rs b/core/src/ops/spaces/update_group/mod.rs new file mode 100644 index 000000000..3ae2ab355 --- /dev/null +++ b/core/src/ops/spaces/update_group/mod.rs @@ -0,0 +1,7 @@ +pub mod action; +pub mod input; +pub mod output; + +pub use action::*; +pub use input::*; +pub use output::*; diff --git a/core/src/ops/spaces/update_group/output.rs b/core/src/ops/spaces/update_group/output.rs new file mode 100644 index 000000000..bfbbd1180 --- /dev/null +++ b/core/src/ops/spaces/update_group/output.rs @@ -0,0 +1,8 @@ +use crate::domain::SpaceGroup; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct UpdateGroupOutput { + pub group: SpaceGroup, +}