diff --git a/api/migration/src/lib.rs b/api/migration/src/lib.rs index 9086086..2cf297e 100644 --- a/api/migration/src/lib.rs +++ b/api/migration/src/lib.rs @@ -8,6 +8,7 @@ mod m20260505_085735_add_todo_checks; mod m20260506_113244_add_todo_time; mod m20260508_075858_add_web_push_subscription; mod m20260525_164927_add_time_recurring_yearly; +mod m20260529_073701_add_delete_time; pub struct Migrator; @@ -23,6 +24,7 @@ impl MigratorTrait for Migrator { Box::new(m20260506_113244_add_todo_time::Migration), Box::new(m20260508_075858_add_web_push_subscription::Migration), Box::new(m20260525_164927_add_time_recurring_yearly::Migration), + Box::new(m20260529_073701_add_delete_time::Migration), ] } } diff --git a/api/migration/src/m20260529_073701_add_delete_time.rs b/api/migration/src/m20260529_073701_add_delete_time.rs new file mode 100644 index 0000000..9dbfc50 --- /dev/null +++ b/api/migration/src/m20260529_073701_add_delete_time.rs @@ -0,0 +1,85 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +use crate::{ + m20260503_093223_add_tag_table::Tag, m20260503_125344_add_category_table::Category, + m20260504_125311_add_basic_todo_table::Todo, +}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Todo::Table) + .add_column(timestamp_null(TodoDelete::Deleted)) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Tag::Table) + .add_column(timestamp_null(TagDelete::Deleted)) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Category::Table) + .add_column(timestamp_null(CategoryDelete::Deleted)) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Todo::Table) + .drop_column(TodoDelete::Deleted) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Tag::Table) + .drop_column(TagDelete::Deleted) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Category::Table) + .drop_column(CategoryDelete::Deleted) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum TodoDelete { + Deleted, +} + +#[derive(DeriveIden)] +enum TagDelete { + Deleted, +} + +#[derive(DeriveIden)] +enum CategoryDelete { + Deleted, +} diff --git a/api/orm/src/entities/category.rs b/api/orm/src/entities/category.rs index 5195c89..4339fff 100644 --- a/api/orm/src/entities/category.rs +++ b/api/orm/src/entities/category.rs @@ -11,6 +11,7 @@ pub struct Model { pub color: String, pub icon: String, pub owner_id: String, + pub deleted: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/api/orm/src/entities/tag.rs b/api/orm/src/entities/tag.rs index f04bbc3..99d2065 100644 --- a/api/orm/src/entities/tag.rs +++ b/api/orm/src/entities/tag.rs @@ -10,6 +10,7 @@ pub struct Model { pub name: String, pub color: String, pub owner_id: String, + pub deleted: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/api/orm/src/entities/todo.rs b/api/orm/src/entities/todo.rs index 20ef926..10263e9 100644 --- a/api/orm/src/entities/todo.rs +++ b/api/orm/src/entities/todo.rs @@ -12,6 +12,7 @@ pub struct Model { pub note: String, pub category_id: Option, pub order: i32, + pub deleted: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/api/services/src/categories.rs b/api/services/src/categories.rs index ee24ec8..871349e 100644 --- a/api/services/src/categories.rs +++ b/api/services/src/categories.rs @@ -1,9 +1,13 @@ +use chrono::Local; use orm::{ OrmConnection, entities::{category, prelude::*}, }; -use sea_orm::{ActiveValue::Set, ColumnTrait, DeleteResult, EntityTrait, QueryFilter}; +use sea_orm::{ + ActiveValue::{NotSet, Set}, + ColumnTrait, EntityTrait, QueryFilter, +}; use thiserror::Error; use types::{Category as CategoryModel, HexColor}; use uuid::Uuid; @@ -28,6 +32,7 @@ impl CategoryService { ) -> Result, CategoryServiceError> { let categories = Category::find() .filter(category::Column::OwnerId.eq(user.id.clone())) + .filter(category::Column::Deleted.is_null()) .all(db) .await?; @@ -51,13 +56,17 @@ impl CategoryService { user: &User, db: &OrmConnection, category_id: Uuid, - ) -> Result { - let category = Category::delete_by_id(category_id) - .filter(category::Column::OwnerId.eq(user.id.clone())) - .exec(db) - .await?; + ) -> Result<(), CategoryServiceError> { + Category::update(category::ActiveModel { + id: Set(category_id), + deleted: Set(Some(Local::now().into())), + ..Default::default() + }) + .filter(category::Column::OwnerId.eq(user.id.clone())) + .exec(db) + .await?; - Ok(category) + Ok(()) } pub async fn create( @@ -71,6 +80,7 @@ impl CategoryService { name: Set(category.name), color: Set(category.color.as_str().to_string()), icon: Set(category.icon), + deleted: NotSet, }) .exec_with_returning(db) .await?; @@ -89,6 +99,7 @@ impl CategoryService { name: Set(category.name), color: Set(category.color.as_str().to_string()), icon: Set(category.icon), + deleted: NotSet, }) .filter(category::Column::OwnerId.eq(user.id.clone())) .exec(db) diff --git a/api/services/src/tags.rs b/api/services/src/tags.rs index 1635e45..b59f644 100644 --- a/api/services/src/tags.rs +++ b/api/services/src/tags.rs @@ -1,9 +1,13 @@ +use chrono::Local; use orm::{ OrmConnection, entities::{prelude::*, tag}, }; -use sea_orm::{ActiveValue::Set, ColumnTrait, DeleteResult, EntityTrait, QueryFilter}; +use sea_orm::{ + ActiveValue::{NotSet, Set}, + ColumnTrait, EntityTrait, QueryFilter, +}; use thiserror::Error; use types::{HexColor, Tag as TagModel}; use uuid::Uuid; @@ -28,6 +32,7 @@ impl TagService { ) -> Result, TagServiceError> { let tags = Tag::find() .filter(tag::Column::OwnerId.eq(user.id.clone())) + .filter(tag::Column::Deleted.is_null()) .all(db) .await?; @@ -51,13 +56,17 @@ impl TagService { user: &User, db: &OrmConnection, tag_id: Uuid, - ) -> Result { - let tag = Tag::delete_by_id(tag_id) - .filter(tag::Column::OwnerId.eq(user.id.clone())) - .exec(db) - .await?; + ) -> Result<(), TagServiceError> { + Tag::update(tag::ActiveModel { + id: Set(tag_id), + deleted: Set(Some(Local::now().into())), + ..Default::default() + }) + .filter(tag::Column::OwnerId.eq(user.id.clone())) + .exec(db) + .await?; - Ok(tag) + Ok(()) } pub async fn create( @@ -70,6 +79,7 @@ impl TagService { owner_id: Set(user.id.clone()), name: Set(tag.name), color: Set(tag.color.as_str().to_string()), + deleted: NotSet, }) .exec_with_returning(db) .await?; @@ -87,6 +97,7 @@ impl TagService { owner_id: Set(user.id.clone()), name: Set(tag.name), color: Set(tag.color.as_str().to_string()), + deleted: NotSet, }) .filter(tag::Column::OwnerId.eq(user.id.clone())) .exec(db) diff --git a/api/services/src/todos.rs b/api/services/src/todos.rs index 49a9702..8d186ff 100644 --- a/api/services/src/todos.rs +++ b/api/services/src/todos.rs @@ -5,8 +5,8 @@ use orm::OrmConnection; use sea_orm::ActiveValue::{NotSet, Set}; use sea_orm::sea_query::Expr; use sea_orm::{ - ColumnTrait, ConnectionTrait, DeleteResult, EntityTrait, IntoActiveModel, ModelTrait, - PaginatorTrait, QueryFilter, QueryOrder, TransactionTrait, + ColumnTrait, ConnectionTrait, EntityTrait, IntoActiveModel, ModelTrait, PaginatorTrait, + QueryFilter, QueryOrder, TransactionTrait, }; use serde::Deserialize; use thiserror::Error; @@ -50,9 +50,15 @@ impl TodoService { pub async fn get_all( user: &User, db: &OrmConnection, + deleted: bool, ) -> Result, TodoServiceError> { let todos = Todo::find() .filter(todo::Column::OwnerId.eq(user.id.clone())) + .filter(if deleted { + todo::Column::Deleted.is_not_null() + } else { + todo::Column::Deleted.is_null() + }) .find_also_related(Category) .order_by_asc(todo::Column::Order) .all(db) @@ -254,6 +260,7 @@ impl TodoService { note: Set(todo.note), category_id: Set(todo.category), order: Set(position), + deleted: NotSet, }) .exec_with_returning(&tx) .await?; @@ -313,6 +320,7 @@ impl TodoService { note: Set(todo.note.clone()), category_id: Set(todo.category), order: NotSet, + deleted: NotSet, }) .filter(todo::Column::OwnerId.eq(user.id.clone())) .exec(&tx) @@ -374,13 +382,17 @@ impl TodoService { user: &User, db: &OrmConnection, id: Uuid, - ) -> Result { - let todo = Todo::delete_by_id(id) - .filter(todo::Column::OwnerId.eq(user.id.clone())) - .exec(db) - .await?; + ) -> Result<(), TodoServiceError> { + Todo::update(todo::ActiveModel { + id: Set(id), + deleted: Set(Some(Local::now().into())), + ..Default::default() + }) + .filter(todo::Column::OwnerId.eq(user.id.clone())) + .exec(db) + .await?; - Ok(todo) + Ok(()) } pub async fn add_check( diff --git a/api/src/routes/todo.rs b/api/src/routes/todo.rs index 5a3f26f..0e270f3 100644 --- a/api/src/routes/todo.rs +++ b/api/src/routes/todo.rs @@ -2,7 +2,7 @@ use std::{convert::Infallible, time::Duration}; use axum::{ Json, Router, - extract::{Path, State}, + extract::{Path, Query, State}, response::{IntoResponse, Response, Sse, sse::Event}, routing::{delete, get, post, put}, }; @@ -62,11 +62,18 @@ pub fn routes(state: AppState) -> Router { .with_state(state) } +#[derive(Deserialize)] +struct GetAllTodosQuery { + deleted: Option, +} + async fn get_all_todos( state: State, user: User, + query: Query, ) -> Result>, ApiError> { - let tags = TodoService::get_all(&user, &state.db_connection).await?; + let tags = + TodoService::get_all(&user, &state.db_connection, query.deleted.unwrap_or(false)).await?; Ok(Json(tags)) } @@ -158,7 +165,7 @@ async fn sse_handler( let user = user.clone(); async move { - TodoService::get_all(&user, &db) + TodoService::get_all(&user, &db, false) .await .map_err(|_| "failed to fetch tags") .and_then(|todos| { diff --git a/api/tests/common/client.rs b/api/tests/common/client.rs index 2ae50b2..0a662ed 100644 --- a/api/tests/common/client.rs +++ b/api/tests/common/client.rs @@ -50,6 +50,14 @@ impl Client { Self::json(self.get_todos().await) } + pub async fn get_deleted_todos(&self) -> TestResponse { + self.get("todos?deleted=true").await + } + + pub async fn get_deleted_todos_json(&self) -> Vec { + Self::json(self.get_deleted_todos().await) + } + pub async fn add_todo(&self, todo: serde_json::Value) -> TestResponse { self.put_json("todos", todo).await } diff --git a/api/tests/todos/delete.rs b/api/tests/todos/delete.rs index 1bf3d37..865958c 100644 --- a/api/tests/todos/delete.rs +++ b/api/tests/todos/delete.rs @@ -1,6 +1,6 @@ use crate::common::client::get_client; use serde_json::json; -use types::Todo; +use types::{Category, HexColor, Tag, Todo}; use uuid::Uuid; #[tokio::test] @@ -41,3 +41,101 @@ async fn delete_unknown_todo() { response.assert_status_not_found(); } + +#[tokio::test] +async fn get_deleted_todo() { + let client = get_client().await; + + let todo_uuid = Uuid::new_v4(); + client + .add_todo_json(json!( + { + "data": { + "uuid": todo_uuid, + "title": "Test Todo", + "note": "Test Note", + "tags": [], + "checks": [], + } + })) + .await; + + let response = client.delete_todo(todo_uuid).await; + response.assert_status_success(); + + let todo: Todo = response.json(); + assert_eq!(todo.uuid, todo_uuid); + + let todos = client.get_deleted_todos_json().await; + assert_eq!(todos.len(), 1); +} + +#[tokio::test] +async fn get_deleted_todo_with_labels() { + let client = get_client().await; + + let todo_uuid = Uuid::new_v4(); + let tag_uuid = Uuid::new_v4(); + let category_uuid = Uuid::new_v4(); + + client + .add_category_json(json!( + { + "uuid": category_uuid, + "name": "Test Category", + "color": "#233212", + "icon": "icon", + } + )) + .await; + + client + .add_tag_json(json!( + { + "uuid": tag_uuid, + "name": "Test Tag", + "color": "#233212", + } + )) + .await; + + let todo = Todo { + uuid: todo_uuid, + title: "Test Todo".to_string(), + note: "Test Note".to_string(), + time: None, + tags: Some(vec![Tag { + uuid: tag_uuid, + name: "Test Tag".to_string(), + color: HexColor::from_text("#233212").unwrap(), + }]), + category: Some(Category { + uuid: category_uuid, + name: "Test Category".to_string(), + color: HexColor::from_text("#233212").unwrap(), + icon: "icon".to_string(), + }), + checks: None, + }; + + client + .add_todo_json(json!( + { + "data": { + "uuid": todo_uuid, + "title": "Test Todo", + "note": "Test Note", + "tags": [tag_uuid], + "category": category_uuid, + } + } + )) + .await; + + let response = client.delete_todo(todo_uuid).await; + response.assert_status_success(); + + let todos = client.get_deleted_todos_json().await; + assert_eq!(todos.len(), 1); + assert_eq!(todos[0], todo); +}