diff --git a/api/migration/src/lib.rs b/api/migration/src/lib.rs index fe9e9de..2038505 100644 --- a/api/migration/src/lib.rs +++ b/api/migration/src/lib.rs @@ -12,6 +12,7 @@ mod m20260529_073701_add_delete_time; mod m20260610_061458_add_todo_reminders; mod m20260907_124516_add_organizations; mod m20260917_155014_add_tag_group; +mod m20261011_093146_add_category_order; pub struct Migrator; @@ -31,6 +32,7 @@ impl MigratorTrait for Migrator { Box::new(m20260610_061458_add_todo_reminders::Migration), Box::new(m20260907_124516_add_organizations::Migration), Box::new(m20260917_155014_add_tag_group::Migration), + Box::new(m20261011_093146_add_category_order::Migration), ] } } diff --git a/api/migration/src/m20261011_093146_add_category_order.rs b/api/migration/src/m20261011_093146_add_category_order.rs new file mode 100644 index 0000000..a06b82b --- /dev/null +++ b/api/migration/src/m20261011_093146_add_category_order.rs @@ -0,0 +1,41 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +use crate::m20260503_125344_add_category_table::Category; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20261011_093146_add_category_order" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Category::Table) + .add_column(integer(CategoryOrder::Order).default(0)) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Category::Table) + .drop_column(CategoryOrder::Order) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum CategoryOrder { + Order, +} diff --git a/api/orm/src/entities/category.rs b/api/orm/src/entities/category.rs index 11ae9e2..5431870 100644 --- a/api/orm/src/entities/category.rs +++ b/api/orm/src/entities/category.rs @@ -13,6 +13,7 @@ pub struct Model { pub icon: String, pub owner_id: String, pub deleted: Option, + pub order: i32, #[sea_orm(has_many)] pub todos: HasMany, } diff --git a/api/services/src/categories.rs b/api/services/src/categories.rs index b768b7e..5dab60a 100644 --- a/api/services/src/categories.rs +++ b/api/services/src/categories.rs @@ -3,7 +3,9 @@ use orm::{OrmConnection, entities::category}; use sea_orm::{ ActiveValue::{NotSet, Set}, - EntityTrait, QueryFilter, + ConnectionTrait, EntityTrait, ExprTrait, IntoActiveModel, Order, QueryFilter, QueryOrder, + TransactionSession, TransactionTrait, + sea_query::Expr, }; use thiserror::Error; use types::tag::{Category, HexColor}; @@ -18,6 +20,9 @@ pub enum CategoryServiceError { #[error("invalid category - caused by: {0}")] InvalidCategory(String), + + #[error("category not found")] + NotFound, } pub struct CategoryService; @@ -30,6 +35,7 @@ impl CategoryService { let categories = category::Entity::find() .filter(category::COLUMN.owner_id.eq(user.id().to_string())) .filter(category::COLUMN.deleted.is_null()) + .order_by(category::Column::Order, Order::Asc) .all(db) .await?; @@ -75,6 +81,14 @@ impl CategoryService { db: &OrmConnection, category: Category, ) -> Result { + let tx = db.begin().await?; + + let position_index = category::Entity::find() + .order_by_asc(category::COLUMN.order) + .one(&tx) + .await? + .map_or(0, |todo| todo.order - 1); + let category = category::Entity::insert(category::ActiveModel { id: Set(category.uuid), owner_id: Set(user.id().to_string()), @@ -82,10 +96,13 @@ impl CategoryService { color: Set(category.color.as_str().to_string()), icon: Set(category.icon), deleted: NotSet, + order: Set(position_index), }) - .exec_with_returning(db) + .exec_with_returning(&tx) .await?; + tx.commit().await?; + to_category_dto(&category.into_ex()) } @@ -101,6 +118,7 @@ impl CategoryService { color: Set(category.color.as_str().to_string()), icon: Set(category.icon), deleted: NotSet, + order: NotSet, }) .validate()? .filter(category::COLUMN.owner_id.eq(user.id().to_string())) @@ -109,6 +127,63 @@ impl CategoryService { to_category_dto(&category.into_ex()) } + + pub async fn move_category( + user: &User, + db: &OrmConnection, + category: Uuid, + target: Uuid, + ) -> Result<(), CategoryServiceError> { + let tx = db.begin().await?; + + let category = category::Entity::find_by_id(category) + .filter(category::COLUMN.owner_id.eq(user.id().to_string())) + .one(&tx) + .await? + .ok_or(CategoryServiceError::NotFound)?; + + let target = category::Entity::find_by_id(target) + .filter(category::COLUMN.owner_id.eq(user.id().to_string())) + .one(&tx) + .await? + .ok_or(CategoryServiceError::NotFound)?; + + if category.order < target.order { + Self::move_to(user, &tx, category, target.order + 1).await?; + } else { + Self::move_to(user, &tx, category, target.order).await?; + } + + tx.commit().await?; + Ok(()) + } + + async fn move_to( + user: &User, + db: &T, + category: category::Model, + target: i32, + ) -> Result<(), sea_orm::DbErr> { + let tx = db.begin().await?; + + let mut model = category.into_active_model(); + model.order = Set(target); + + category::Entity::update_many() + .col_expr( + category::COLUMN.order, + Expr::col(category::COLUMN.order).add(1), + ) + .filter(category::COLUMN.order.gte(target)) + .filter(category::COLUMN.owner_id.eq(user.id().to_string())) + .exec(db) + .await?; + + category::Entity::update(model).exec(db).await?; + + tx.commit().await?; + Ok(()) + } } pub fn to_category_dto(value: &category::ModelEx) -> Result { diff --git a/api/src/routes/categories.rs b/api/src/routes/categories.rs index 2071288..7ff2835 100644 --- a/api/src/routes/categories.rs +++ b/api/src/routes/categories.rs @@ -3,6 +3,7 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{delete, post, put}; use axum::{Json, Router, extract::State, routing::get}; use http::StatusCode; +use serde::Deserialize; use thiserror::Error; use tracing::warn; use types::tag::Category; @@ -27,7 +28,10 @@ enum ApiError { impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match &self { - ApiError::ServiceError(_) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::ServiceError(error) => match error { + CategoryServiceError::NotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }, ApiError::CategoryNotFound => StatusCode::NOT_FOUND, ApiError::KnownUuid => StatusCode::BAD_REQUEST, }; @@ -43,6 +47,7 @@ pub fn routes(state: AppState) -> Router { .route("/", post(add_category)) .route("/", put(update_category)) .route("/{category_uuid}", delete(delete_category)) + .route("/move", post(move_category)) .with_state(state) } @@ -106,3 +111,22 @@ async fn delete_category( state.event_service.broadcast_category(user.id()); Ok(Json(to_be_deleted_category)) } + +#[derive(Debug, Deserialize)] +pub struct MoveTodoCategory { + #[serde(rename = "toMove")] + pub to_move: Uuid, + pub to: Uuid, +} + +async fn move_category( + state: State, + user: User, + Json(move_data): Json, +) -> Result<(), ApiError> { + CategoryService::move_category(&user, &state.db_connection, move_data.to_move, move_data.to) + .await?; + + state.event_service.broadcast_category(user.id()); + Ok(()) +} diff --git a/api/tests/categories.rs b/api/tests/categories.rs index 9ae4c10..b571bac 100644 --- a/api/tests/categories.rs +++ b/api/tests/categories.rs @@ -5,5 +5,6 @@ mod categories { mod add; mod delete; mod get; + mod position; mod update; } diff --git a/api/tests/categories/add.rs b/api/tests/categories/add.rs index 9778e33..7e2ffb7 100644 --- a/api/tests/categories/add.rs +++ b/api/tests/categories/add.rs @@ -96,3 +96,51 @@ async fn add_invalid_category() { .await; response.assert_status_unprocessable_entity(); } + +#[tokio::test] +async fn add_mutliple_categories() { + let server = get_server().await; + let client = Client::new(&server); + + let category1_uuid = Uuid::new_v4(); + let category2_uuid = Uuid::new_v4(); + let category3_uuid = Uuid::new_v4(); + + client + .add_category_json(json!( + { + "uuid": category1_uuid, + "name": "Test category", + "color": "#233212", + "icon": "icon", + } + )) + .await; + client + .add_category_json(json!( + { + "uuid": category2_uuid, + "name": "Test category", + "color": "#233212", + "icon": "icon", + } + )) + .await; + client + .add_category_json(json!( + { + "uuid": category3_uuid, + "name": "Test category", + "color": "#233212", + "icon": "icon", + } + )) + .await; + + let categories = client.get_categories_json().await; + + assert_eq!(categories.len(), 3); + assert_eq!(categories[2].uuid, category1_uuid); + assert_eq!(categories[1].uuid, category2_uuid); + assert_eq!(categories[0].uuid, category3_uuid); +} diff --git a/api/tests/categories/position.rs b/api/tests/categories/position.rs new file mode 100644 index 0000000..7764c90 --- /dev/null +++ b/api/tests/categories/position.rs @@ -0,0 +1,125 @@ +use crate::common::{Client, get_server}; +use serde_json::json; +use uuid::Uuid; + +#[tokio::test] +async fn move_category() { + let server = get_server().await; + let client = Client::new(&server); + + let category1_uuid = Uuid::new_v4(); + let category2_uuid = Uuid::new_v4(); + let category3_uuid = Uuid::new_v4(); + + client + .add_category_json(json!( + { + "uuid": category1_uuid, + "name": "Test Category", + "color": "#233212", + "icon": "icon", + } + )) + .await; + client + .add_category_json(json!( + { + "uuid": category2_uuid, + "name": "Test Category", + "color": "#233212", + "icon": "icon", + } + )) + .await; + client + .add_category_json(json!( + { + "uuid": category3_uuid, + "name": "Test Category", + "color": "#233212", + "icon": "icon", + } + )) + .await; + + client + .move_category(json!({ "toMove": category1_uuid, "to": category3_uuid })) + .await; + + let categories = client.get_categories_json().await; + assert_eq!(categories.len(), 3); + assert_eq!(categories[0].uuid, category1_uuid); + assert_eq!(categories[1].uuid, category3_uuid); + assert_eq!(categories[2].uuid, category2_uuid); + + client + .move_category(json!({ "toMove": category2_uuid, "to": category1_uuid })) + .await; + + let categories = client.get_categories_json().await; + assert_eq!(categories.len(), 3); + assert_eq!(categories[0].uuid, category2_uuid); + assert_eq!(categories[1].uuid, category1_uuid); + assert_eq!(categories[2].uuid, category3_uuid); + + client + .move_category(json!({ "toMove": category1_uuid, "to": category3_uuid })) + .await; + + let categories = client.get_categories_json().await; + assert_eq!(categories.len(), 3); + assert_eq!(categories[0].uuid, category2_uuid); + assert_eq!(categories[1].uuid, category3_uuid); + assert_eq!(categories[2].uuid, category1_uuid); +} + +#[tokio::test] +async fn invalid_move_category() { + let server = get_server().await; + let client = Client::new(&server); + + let category1_uuid = Uuid::new_v4(); + let category2_uuid = Uuid::new_v4(); + let category3_uuid = Uuid::new_v4(); + + let unknown_uuid = Uuid::new_v4(); + + client + .add_category_json(json!( + { + "uuid": category1_uuid, + "name": "Test Category", + "color": "#233212", + "icon": "icon", + } + )) + .await; + client + .add_category_json(json!( + { + "uuid": category2_uuid, + "name": "Test Category", + "color": "#233212", + "icon": "icon", + } + )) + .await; + client + .add_category_json(json!( + { + "uuid": category3_uuid, + "name": "Test Category", + "color": "#233212", + "icon": "icon", + } + )) + .await; + + let response = client + .move_category(json!({ "toMove": unknown_uuid, "to": category3_uuid })) + .await; + response.assert_status_not_found(); + + let response = client.move_category(json!({ "toMove": "unknown" })).await; + response.assert_status_unprocessable_entity(); +} diff --git a/api/tests/common.rs b/api/tests/common.rs index 6ef38f1..367900b 100644 --- a/api/tests/common.rs +++ b/api/tests/common.rs @@ -250,6 +250,10 @@ impl<'a> Client<'a> { Self::json(&self.delete_category(category_uuid).await) } + pub async fn move_category(&self, value: serde_json::Value) -> TestResponse { + self.post_json("categories/move", value).await + } + pub async fn move_todo(&self, value: serde_json::Value) -> TestResponse { self.post_json("todos/move", value).await } diff --git a/api/tests/sse.rs b/api/tests/sse.rs index bf9025f..4a99361 100644 --- a/api/tests/sse.rs +++ b/api/tests/sse.rs @@ -327,6 +327,65 @@ async fn sends_events_on_check_remove_todo() { assert_eq!(todos.len(), 1); } +#[tokio::test] +async fn sends_events_on_move_todo() { + let server = get_server().await; + let client = Client::new(&server); + + let todo1_uuid = Uuid::new_v4(); + let todo2_uuid = Uuid::new_v4(); + let todo3_uuid = Uuid::new_v4(); + + client + .add_todo_json(json!({ + "data": { + "uuid": todo1_uuid, + "title": "Test Todo", + "note": "Test Note", + "tags": [], + "checks": [], + } + })) + .await; + client + .add_todo_json(json!({ + "data": { + "uuid": todo2_uuid, + "title": "Test Todo", + "note": "Test Note", + "tags": [], + "checks": [], + } + })) + .await; + client + .add_todo_json(json!({ + "data": { + "uuid": todo3_uuid, + "title": "Test Todo", + "note": "Test Note", + "tags": [], + "checks": [], + } + })) + .await; + + let mut stream = connect_sse(&server, client.uuid().unwrap()).await; + + client + .move_todo(json!({ "toMove": todo1_uuid, "to": todo3_uuid })) + .await; + + let todos: Vec = timeout(Duration::from_secs(2), read_event(&mut stream, "todos")) + .await + .unwrap(); + + assert_eq!(todos.len(), 3); + assert_eq!(todos[0].uuid, todo1_uuid); + assert_eq!(todos[1].uuid, todo3_uuid); + assert_eq!(todos[2].uuid, todo2_uuid); +} + #[tokio::test] async fn sends_events_on_add_tag() { let server = get_server().await; @@ -518,3 +577,62 @@ async fn sends_events_on_remove_category() { assert_eq!(categories.len(), 0); } + +#[tokio::test] +async fn sends_events_on_move_category() { + let server = get_server().await; + let client = Client::new(&server); + + let category1_uuid = Uuid::new_v4(); + let category2_uuid = Uuid::new_v4(); + let category3_uuid = Uuid::new_v4(); + + client + .add_category_json(json!( + { + "uuid": category1_uuid, + "name": "Test Category", + "color": "#233212", + "icon": "icon", + } + )) + .await; + client + .add_category_json(json!( + { + "uuid": category2_uuid, + "name": "Test Category", + "color": "#233212", + "icon": "icon", + } + )) + .await; + client + .add_category_json(json!( + { + "uuid": category3_uuid, + "name": "Test Category", + "color": "#233212", + "icon": "icon", + } + )) + .await; + + let mut stream = connect_sse(&server, client.uuid().unwrap()).await; + + client + .move_category(json!({ "toMove": category1_uuid, "to": category3_uuid })) + .await; + + let categories: Vec = timeout( + Duration::from_secs(2), + read_event(&mut stream, "categories"), + ) + .await + .unwrap(); + + assert_eq!(categories.len(), 3); + assert_eq!(categories[0].uuid, category1_uuid); + assert_eq!(categories[1].uuid, category3_uuid); + assert_eq!(categories[2].uuid, category2_uuid); +}