diff --git a/frontend/src/components/UserListDrawer.vue b/frontend/src/components/UserListDrawer.vue new file mode 100644 index 0000000..dc548b4 --- /dev/null +++ b/frontend/src/components/UserListDrawer.vue @@ -0,0 +1,63 @@ + + + + + + Utilisateurs + + + + + + + + + + {{ getUserInitials(user.username) }} + + + + + + + Aucun utilisateur à afficher. + + + diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index c7cf0aa..2e86bd4 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -1,15 +1,24 @@ - mdi-square @@ -86,6 +95,15 @@ const getServerColor = (str: string): string => { mdi-circle mdi-triangle + + { > + + + @@ -184,4 +207,4 @@ const getServerColor = (str: string): string => { flex-direction: column; gap: 1rem; } - \ No newline at end of file + diff --git a/frontend/src/pages/server/index.vue b/frontend/src/pages/server/index.vue index 0ae4045..f623182 100644 --- a/frontend/src/pages/server/index.vue +++ b/frontend/src/pages/server/index.vue @@ -142,7 +142,11 @@ function onChannelContextMenu(event: MouseEvent, channel: any) { width="100%" > - + diff --git a/src/repositories/server_item_order.rs b/src/repositories/server_item_order.rs index 7751a48..9ab2406 100644 --- a/src/repositories/server_item_order.rs +++ b/src/repositories/server_item_order.rs @@ -1,9 +1,6 @@ use crate::models::server_item_order; -use crate::models::server_item_order::OrderedResourceType; use crate::repositories::{AnyResult, RepositoryContext}; -use sea_orm::{ - ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, TransactionTrait, -}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; use std::sync::Arc; use uuid::Uuid; @@ -17,29 +14,9 @@ impl ServerItemOrderRepository { pub async fn get_by_server(&self, server_id: Uuid) -> AnyResult> { Ok(server_item_order::Entity::find() .filter(server_item_order::Column::ServerId.eq(server_id)) + .order_by_asc(server_item_order::Column::ParentCategoryId) .order_by_asc(server_item_order::Column::OrderKey) .all(&self.context.db) .await?) } - - /// Réorganise en bloc (reorder) une liste d'éléments dans un serveur ou une catégorie - pub async fn update_orders( - &self, - server_id: Uuid, - items: Vec<(Uuid, OrderedResourceType, Option, i64)>, // (resource_id, resource_type, parent_category_id, order_key) - ) -> AnyResult<()> { - self.context - .db - .transaction::<_, (), anyhow::Error>(|txn| { - Box::pin(async move { - for (resource_id, res_type, parent_cat_id, order_key) in items { - // Logique de mise à jour / upsert des order_key - } - Ok(()) - }) - }) - .await?; - - Ok(()) - } } diff --git a/src/repositories/server_tree.rs b/src/repositories/server_tree.rs index 79e68e3..a8db6f5 100644 --- a/src/repositories/server_tree.rs +++ b/src/repositories/server_tree.rs @@ -2,7 +2,7 @@ use crate::models::{category, channel, computed_permission, server_item_order}; use crate::permissions::ChannelPermission; use crate::repositories::types::{CategoryWithPermissions, ChannelWithPermissions, ServerTreeData}; use crate::repositories::{AnyResult, RepositoryContext}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; @@ -19,6 +19,8 @@ impl ServerTreeRepository { Ok::<_, anyhow::Error>( server_item_order::Entity::find() .filter(server_item_order::Column::ServerId.eq(server_id)) + .order_by_asc(server_item_order::Column::ParentCategoryId) + .order_by_asc(server_item_order::Column::OrderKey) .all(&self.context.db) .await?, ) diff --git a/src/routes/server/mapper.rs b/src/routes/server/mapper.rs index 26e21d3..470e288 100644 --- a/src/routes/server/mapper.rs +++ b/src/routes/server/mapper.rs @@ -67,9 +67,14 @@ pub fn build_server_tree( channels: Vec, categories: Vec, ) -> ServerTreeResponse { - let order_map: HashMap = orders + let order_map: HashMap<(Option, Uuid), i64> = orders .into_iter() - .map(|order| (order.resource_id, order.order_key)) + .map(|order| { + ( + (order.parent_category_id, order.resource_id), + order.order_key, + ) + }) .collect(); let mut category_channels: HashMap> = HashMap::new(); @@ -88,7 +93,12 @@ pub fn build_server_tree( // Trier les canaux dans chaque catégorie par leur order_key for chans in category_channels.values_mut() { - chans.sort_by_key(|c| order_map.get(&c.channel.id).copied().unwrap_or(i64::MAX)); + chans.sort_by_key(|c| { + order_map + .get(&(c.channel.category_id, c.channel.id)) + .copied() + .unwrap_or(i64::MAX) + }); } // Préparer les éléments racine (catégories & canaux orphelins) avec leur order_key @@ -96,7 +106,7 @@ pub fn build_server_tree( for cat_with_perm in categories { let cat_id = cat_with_perm.category.id; - let order_key = order_map.get(&cat_id).copied().unwrap_or(i64::MAX); + let order_key = order_map.get(&(None, cat_id)).copied().unwrap_or(i64::MAX); let chans = category_channels.remove(&cat_id).unwrap_or_default(); let cat_perm_bits = cat_with_perm.permissions.map(|p| p.bits()).unwrap_or(0); @@ -121,7 +131,7 @@ pub fn build_server_tree( for chan_with_perm in orphan_channels { let order_key = order_map - .get(&chan_with_perm.channel.id) + .get(&(None, chan_with_perm.channel.id)) .copied() .unwrap_or(i64::MAX); let chan_perm_bits = chan_with_perm.permissions.map(|p| p.bits()).unwrap_or(0); @@ -142,3 +152,92 @@ pub fn build_server_tree( ServerTreeResponse { items } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{category, channel}; + use crate::permissions::ChannelPermission; + use chrono::Utc; + + fn channel(id: Uuid, category_id: Option, name: &str) -> ChannelWithPermissions { + ChannelWithPermissions { + channel: channel::Model { + id, + server_id: Some(Uuid::new_v4()), + category_id, + channel_type: channel::ChannelType::Text, + name: Some(name.to_owned()), + created_at: Utc::now(), + updated_at: Utc::now(), + }, + permissions: Some(ChannelPermission::empty()), + } + } + + #[test] + fn sorts_channels_by_order_key_within_each_category() { + let category_id = Uuid::new_v4(); + let first_id = Uuid::new_v4(); + let second_id = Uuid::new_v4(); + + let category = CategoryWithPermissions { + category: category::Model { + id: category_id, + server_id: Uuid::new_v4(), + name: "Discussion".to_owned(), + created_at: Utc::now(), + updated_at: Utc::now(), + }, + permissions: Some(ChannelPermission::empty()), + }; + + let orders = vec![ + server_item_order::Model { + id: Uuid::new_v4(), + server_id: category.category.server_id, + resource_id: category_id, + resource_type: server_item_order::OrderedResourceType::Category, + parent_category_id: None, + order_key: 1, + created_at: Utc::now(), + updated_at: Utc::now(), + }, + server_item_order::Model { + id: Uuid::new_v4(), + server_id: category.category.server_id, + resource_id: first_id, + resource_type: server_item_order::OrderedResourceType::Channel, + parent_category_id: Some(category_id), + order_key: 2, + created_at: Utc::now(), + updated_at: Utc::now(), + }, + server_item_order::Model { + id: Uuid::new_v4(), + server_id: category.category.server_id, + resource_id: second_id, + resource_type: server_item_order::OrderedResourceType::Channel, + parent_category_id: Some(category_id), + order_key: 1, + created_at: Utc::now(), + updated_at: Utc::now(), + }, + ]; + + let response = build_server_tree( + orders, + vec![ + channel(first_id, Some(category_id), "second"), + channel(second_id, Some(category_id), "first"), + ], + vec![category], + ); + + let ServerExplorerItemResponse::Category(_, channels) = &response.items[0] else { + panic!("expected a category"); + }; + assert_eq!(channels[0].name.as_deref(), Some("first")); + assert_eq!(channels[1].name.as_deref(), Some("second")); + } +} diff --git a/src/services/category.rs b/src/services/category.rs index 6ca8933..864c9a6 100644 --- a/src/services/category.rs +++ b/src/services/category.rs @@ -1,9 +1,7 @@ use crate::models::category; -use crate::models::server_item_order::{self, OrderedResourceType}; +use crate::models::server_item_order::OrderedResourceType; use crate::services::ServicesContext; -use sea_orm::{ - ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait, -}; +use sea_orm::{ActiveModelTrait, EntityTrait, Set, TransactionTrait}; use std::sync::Arc; use uuid::Uuid; @@ -24,6 +22,13 @@ impl CategoryService { ) -> Result { let db = &self.service_context.repositories.server.context.db; let event_bus = &self.service_context.event_bus; + let server_order = self + .service_context + .services + .get() + .expect("services initialized") + .server_order + .clone(); let cat = db .transaction::<_, category::Model, anyhow::Error>(|txn| { @@ -35,25 +40,9 @@ impl CategoryService { }; let cat = active.insert(txn).await?; - let max_order: Option = server_item_order::Entity::find() - .filter(server_item_order::Column::ServerId.eq(server_id)) - .filter(server_item_order::Column::ParentCategoryId.is_null()) - .select_only() - .column_as(server_item_order::Column::OrderKey.max(), "max_key") - .into_tuple::>() - .one(txn) - .await? - .flatten(); - - let order_item = server_item_order::ActiveModel { - server_id: Set(server_id), - resource_id: Set(cat.id), - resource_type: Set(OrderedResourceType::Category), - parent_category_id: Set(None), - order_key: Set(max_order.unwrap_or(0) + 1), - ..Default::default() - }; - order_item.insert(txn).await?; + server_order + .append(txn, server_id, cat.id, OrderedResourceType::Category, None) + .await?; Ok(cat) }) @@ -98,10 +87,12 @@ impl CategoryService { let txn = db.begin().await?; - server_item_order::Entity::delete_many() - .filter(server_item_order::Column::ResourceId.eq(id)) - .filter(server_item_order::Column::ResourceType.eq(OrderedResourceType::Category)) - .exec(&txn) + self.service_context + .services + .get() + .expect("services initialized") + .server_order + .remove(&txn, id, OrderedResourceType::Category) .await?; let res = category::Entity::delete_by_id(id).exec(&txn).await?; diff --git a/src/services/channel.rs b/src/services/channel.rs index 39c88b4..4661a75 100644 --- a/src/services/channel.rs +++ b/src/services/channel.rs @@ -1,10 +1,11 @@ use crate::domain::dto::channel::{CreateChannelRequest, UpdateChannelRequest}; +use crate::models::server_item_order::OrderedResourceType; use crate::models::{channel, role}; use crate::permissions::PermissionSet; use crate::services::ServicesContext; use crate::services::permission::PermissionService; use sea_orm::{ - ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait, + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait, }; use std::sync::Arc; use uuid::Uuid; @@ -25,6 +26,13 @@ impl ChannelService { ) -> Result { let db = &self.service_context.repositories.server.context.db; let event_bus = &self.service_context.event_bus; + let server_order = self + .service_context + .services + .get() + .expect("services initialized") + .server_order + .clone(); let channel = db .transaction::<_, channel::Model, anyhow::Error>(|txn| { @@ -42,35 +50,15 @@ impl ChannelService { // 2. Si server_id est présent, enregistrement de l'ordre d'affichage if let Some(server_id) = payload.server_id { - let max_order: Option = - crate::models::server_item_order::Entity::find() - .filter( - crate::models::server_item_order::Column::ServerId - .eq(server_id), - ) - .select_only() - .column_as( - crate::models::server_item_order::Column::OrderKey.max(), - "max_key", - ) - .into_tuple::>() - .one(txn) - .await? - .flatten(); - - let next_order = max_order.unwrap_or(0) + 1; - - let order_item = crate::models::server_item_order::ActiveModel { - server_id: Set(server_id), - resource_id: Set(channel.id), - resource_type: Set( - crate::models::server_item_order::OrderedResourceType::Channel, - ), - parent_category_id: Set(payload.category_id), - order_key: Set(next_order), - ..Default::default() - }; - order_item.insert(txn).await?; + server_order + .append( + txn, + server_id, + channel.id, + OrderedResourceType::Channel, + payload.category_id, + ) + .await?; // 3. Attribution des permissions par défaut au rôle par défaut if let Some(default_role) = role::Entity::find() @@ -130,17 +118,19 @@ impl ChannelService { let channel = active.update(&txn).await?; - // Update server_item_order parent_category_id or server_id if needed - if let Some(server_id) = channel.server_id { - crate::models::server_item_order::Entity::update_many() - .set(crate::models::server_item_order::ActiveModel { - parent_category_id: Set(channel.category_id), - ..Default::default() - }) - .filter(crate::models::server_item_order::Column::ResourceId.eq(channel.id)) - .exec(&txn) - .await?; - } + self.service_context + .services + .get() + .expect("services initialized") + .server_order + .sync( + &txn, + channel.id, + OrderedResourceType::Channel, + channel.server_id, + channel.category_id, + ) + .await?; txn.commit().await?; @@ -155,10 +145,12 @@ impl ChannelService { let txn = db.begin().await?; - // Delete associated order records - crate::models::server_item_order::Entity::delete_many() - .filter(crate::models::server_item_order::Column::ResourceId.eq(id)) - .exec(&txn) + self.service_context + .services + .get() + .expect("services initialized") + .server_order + .remove(&txn, id, OrderedResourceType::Channel) .await?; let res = channel::Entity::delete_by_id(id).exec(&txn).await?; diff --git a/src/services/server_order.rs b/src/services/server_order.rs index fcc93de..e74ac43 100644 --- a/src/services/server_order.rs +++ b/src/services/server_order.rs @@ -1,5 +1,10 @@ +use crate::models::server_item_order::{self, OrderedResourceType}; use crate::services::ServicesContext; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter, QuerySelect, Set, +}; use std::sync::Arc; +use uuid::Uuid; #[derive(Debug, Clone)] pub struct ServerOrderService { @@ -11,9 +16,303 @@ impl ServerOrderService { Self { service_context } } + pub(crate) async fn append( + &self, + txn: &DatabaseTransaction, + server_id: Uuid, + resource_id: Uuid, + resource_type: OrderedResourceType, + parent_category_id: Option, + ) -> Result { + self.sync( + txn, + resource_id, + resource_type, + Some(server_id), + parent_category_id, + ) + .await? + .ok_or_else(|| anyhow::anyhow!("Unable to create server order")) + } + + pub(crate) async fn sync( + &self, + txn: &DatabaseTransaction, + resource_id: Uuid, + resource_type: OrderedResourceType, + server_id: Option, + parent_category_id: Option, + ) -> Result, anyhow::Error> { + let existing = server_item_order::Entity::find() + .filter(server_item_order::Column::ResourceId.eq(resource_id)) + .filter(server_item_order::Column::ResourceType.eq(resource_type)) + .one(txn) + .await?; + + let Some(server_id) = server_id else { + if let Some(order) = existing { + server_item_order::Entity::delete_by_id(order.id) + .exec(txn) + .await?; + } + return Ok(None); + }; + + let order_key = match existing.as_ref() { + Some(order) + if order.server_id == server_id + && order.parent_category_id == parent_category_id => + { + order.order_key + } + _ => { + self.next_order_key(txn, server_id, parent_category_id) + .await? + } + }; + + let order = if let Some(order) = existing { + let mut active: server_item_order::ActiveModel = order.into(); + active.server_id = Set(server_id); + active.parent_category_id = Set(parent_category_id); + active.order_key = Set(order_key); + active.update(txn).await? + } else { + server_item_order::ActiveModel { + server_id: Set(server_id), + resource_id: Set(resource_id), + resource_type: Set(resource_type), + parent_category_id: Set(parent_category_id), + order_key: Set(order_key), + ..Default::default() + } + .insert(txn) + .await? + }; + + Ok(Some(order)) + } + + pub(crate) async fn remove( + &self, + txn: &DatabaseTransaction, + resource_id: Uuid, + resource_type: OrderedResourceType, + ) -> Result<(), anyhow::Error> { + server_item_order::Entity::delete_many() + .filter(server_item_order::Column::ResourceId.eq(resource_id)) + .filter(server_item_order::Column::ResourceType.eq(resource_type)) + .exec(txn) + .await?; + Ok(()) + } + + async fn next_order_key( + &self, + txn: &DatabaseTransaction, + server_id: Uuid, + parent_category_id: Option, + ) -> Result { + let mut query = server_item_order::Entity::find() + .filter(server_item_order::Column::ServerId.eq(server_id)); + query = match parent_category_id { + Some(category_id) => { + query.filter(server_item_order::Column::ParentCategoryId.eq(category_id)) + } + None => query.filter(server_item_order::Column::ParentCategoryId.is_null()), + }; + + let max_order = query + .select_only() + .column_as(server_item_order::Column::OrderKey.max(), "max_key") + .into_tuple::>() + .one(txn) + .await? + .flatten() + .unwrap_or(0); + + Ok(max_order + 1) + } + pub async fn exemple_call_other_service(&self) { if let Some(services) = self.service_context.services.get() { services.permission_sync.start_listen_event().await; } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{category, server}; + use crate::repositories::Repositories; + use event_bus::EventBus; + use migration::{Migrator, MigratorTrait}; + use sea_orm::{ + ActiveModelTrait, ColumnTrait, Database, DatabaseConnection, EntityTrait, Set, + TransactionTrait, + }; + + async fn service() -> ServerOrderService { + let db = Database::connect("sqlite::memory:").await.unwrap(); + Migrator::up(&db, None).await.unwrap(); + + let context = Arc::new(ServicesContext { + repositories: Arc::new(Repositories::new(db)), + event_bus: Arc::new(EventBus::new()), + services: std::sync::OnceLock::new(), + }); + ServerOrderService::new(context) + } + + async fn seed_server(db: &DatabaseConnection, server_id: Uuid) { + server::ActiveModel { + id: Set(server_id), + name: Set("test".to_owned()), + is_default: Set(false), + ..Default::default() + } + .insert(db) + .await + .unwrap(); + } + + #[tokio::test] + async fn sync_uses_scoped_keys_and_preserves_position() { + let service = service().await; + let db = &service.service_context.repositories.server.context.db; + let server_id = Uuid::new_v4(); + let category_id = Uuid::new_v4(); + let category_resource_id = Uuid::new_v4(); + let root_channel_id = Uuid::new_v4(); + let nested_channel_id = Uuid::new_v4(); + seed_server(db, server_id).await; + category::ActiveModel { + id: Set(category_id), + server_id: Set(server_id), + name: Set("category".to_owned()), + ..Default::default() + } + .insert(db) + .await + .unwrap(); + + let txn = db.begin().await.unwrap(); + service + .append( + &txn, + server_id, + category_resource_id, + OrderedResourceType::Category, + None, + ) + .await + .unwrap(); + service + .append( + &txn, + server_id, + root_channel_id, + OrderedResourceType::Channel, + None, + ) + .await + .unwrap(); + service + .append( + &txn, + server_id, + nested_channel_id, + OrderedResourceType::Channel, + Some(category_id), + ) + .await + .unwrap(); + txn.commit().await.unwrap(); + + let txn = db.begin().await.unwrap(); + let unchanged = service + .sync( + &txn, + nested_channel_id, + OrderedResourceType::Channel, + Some(server_id), + Some(category_id), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(unchanged.order_key, 1); + + let moved = service + .sync( + &txn, + nested_channel_id, + OrderedResourceType::Channel, + Some(server_id), + None, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(moved.order_key, 3); + txn.commit().await.unwrap(); + } + + #[tokio::test] + async fn sync_none_removes_order_and_rollback_is_atomic() { + let service = service().await; + let db = &service.service_context.repositories.server.context.db; + let server_id = Uuid::new_v4(); + let resource_id = Uuid::new_v4(); + seed_server(db, server_id).await; + + let txn = db.begin().await.unwrap(); + service + .append( + &txn, + server_id, + resource_id, + OrderedResourceType::Channel, + None, + ) + .await + .unwrap(); + txn.rollback().await.unwrap(); + + assert!( + server_item_order::Entity::find() + .filter(server_item_order::Column::ResourceId.eq(resource_id)) + .one(db) + .await + .unwrap() + .is_none() + ); + + let txn = db.begin().await.unwrap(); + service + .append( + &txn, + server_id, + resource_id, + OrderedResourceType::Channel, + None, + ) + .await + .unwrap(); + service + .sync(&txn, resource_id, OrderedResourceType::Channel, None, None) + .await + .unwrap(); + txn.commit().await.unwrap(); + + assert!( + server_item_order::Entity::find() + .filter(server_item_order::Column::ResourceId.eq(resource_id)) + .one(db) + .await + .unwrap() + .is_none() + ); + } +}