diff --git a/api/migration/src/lib.rs b/api/migration/src/lib.rs index 2cf297e..93313c3 100644 --- a/api/migration/src/lib.rs +++ b/api/migration/src/lib.rs @@ -9,6 +9,7 @@ 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; +mod m20260610_061458_add_todo_reminders; pub struct Migrator; @@ -25,6 +26,7 @@ impl MigratorTrait for Migrator { 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), + Box::new(m20260610_061458_add_todo_reminders::Migration), ] } } diff --git a/api/migration/src/m20260610_061458_add_todo_reminders.rs b/api/migration/src/m20260610_061458_add_todo_reminders.rs new file mode 100644 index 0000000..8295a6a --- /dev/null +++ b/api/migration/src/m20260610_061458_add_todo_reminders.rs @@ -0,0 +1,52 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +use crate::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 + .create_table( + Table::create() + .table(Reminder::Table) + .if_not_exists() + .col(pk_auto(Reminder::Id)) + .col(uuid(Reminder::TodoId)) + .col(enumeration( + Reminder::Format, + Reminder::Format, + ["time", "relative"], + )) + .col(date_time_null(Reminder::Time)) + .col(integer_null(Reminder::Offset)) + .foreign_key( + ForeignKey::create() + .from(Reminder::Table, Reminder::TodoId) + .to(Todo::Table, Todo::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Reminder::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Reminder { + Table, + Id, + TodoId, + Format, + Time, + Offset, +} diff --git a/api/orm/src/entities/mod.rs b/api/orm/src/entities/mod.rs index ae25281..d0eff40 100644 --- a/api/orm/src/entities/mod.rs +++ b/api/orm/src/entities/mod.rs @@ -4,6 +4,7 @@ pub mod prelude; pub mod category; pub mod check; +pub mod reminder; pub mod tag; pub mod time; pub mod todo; diff --git a/api/orm/src/entities/prelude.rs b/api/orm/src/entities/prelude.rs index 89dbc72..76824d7 100644 --- a/api/orm/src/entities/prelude.rs +++ b/api/orm/src/entities/prelude.rs @@ -2,6 +2,7 @@ pub use super::category::Entity as Category; pub use super::check::Entity as Check; +pub use super::reminder::Entity as Reminder; pub use super::tag::Entity as Tag; pub use super::time::Entity as Time; pub use super::todo::Entity as Todo; diff --git a/api/orm/src/entities/reminder.rs b/api/orm/src/entities/reminder.rs new file mode 100644 index 0000000..5e7687b --- /dev/null +++ b/api/orm/src/entities/reminder.rs @@ -0,0 +1,26 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "reminder")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + pub todo_id: Uuid, + #[sea_orm(column_type = "Text")] + pub format: String, + pub time: Option, + pub offset: Option, + #[sea_orm( + belongs_to, + from = "todo_id", + to = "id", + on_update = "Cascade", + on_delete = "Cascade" + )] + pub todo: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/api/orm/src/entities/todo.rs b/api/orm/src/entities/todo.rs index ce9d4de..e479083 100644 --- a/api/orm/src/entities/todo.rs +++ b/api/orm/src/entities/todo.rs @@ -24,6 +24,8 @@ pub struct Model { pub category: HasOne, #[sea_orm(has_many)] pub checks: HasMany, + #[sea_orm(has_many)] + pub reminders: HasMany, #[sea_orm(has_one)] pub time: HasOne, #[sea_orm(has_many, via = "todo_tag")] diff --git a/api/services/src/todos.rs b/api/services/src/todos.rs index 3c73998..dda51f8 100644 --- a/api/services/src/todos.rs +++ b/api/services/src/todos.rs @@ -11,13 +11,16 @@ use sea_orm::{ use thiserror::Error; use tracing::warn; use types::models::TodoPosition; -use types::{CreateTodoData, Time as TimeModel, TimeRange, TimeRecurring, Todo as TodoModel}; +use types::{ + CreateTodoData, Reminder as ReminderModel, ReminderRelative, ReminderTime, Time as TimeModel, + TimeRange, TimeRecurring, Todo as TodoModel, +}; use uuid::Uuid; use crate::User; use crate::categories::to_category_dto; use crate::tags::to_tag_dto; -use orm::entities::{category, check, prelude::*, tag, time, todo, todo_tag}; +use orm::entities::{category, check, prelude::*, reminder, tag, time, todo, todo_tag}; #[derive(Error, Debug, PartialEq)] pub enum TodoServiceError { @@ -33,6 +36,9 @@ pub enum TodoServiceError { #[error("invalid time - caused by: {0}")] InvalidTime(String), + #[error("invalid reminder - caused by: {0}")] + InvalidReminder(String), + #[error("not found")] NotFound, } @@ -103,6 +109,24 @@ impl TodoService { acc }); + let mut reminders: HashMap> = Reminder::find() + .filter(reminder::COLUMN.todo_id.is_in(todo_ids.clone())) + .all(db) + .await? + .into_iter() + .fold(HashMap::new(), |mut acc, reminder| { + let todo = reminder.todo_id; + match Self::to_reminder_dto(reminder) { + Ok(reminder_model) => { + acc.entry(todo).or_default().push(reminder_model); + } + Err(err) => { + warn!("Failed to convert reminder: {} - todo-id: {}", err, todo); + } + } + acc + }); + let mut times: HashMap> = Time::find() .filter(time::COLUMN.todo_id.is_in(todo_ids)) .all(db) @@ -127,6 +151,7 @@ impl TodoService { let tags = tags.remove(&todo.id); let checks = checks.remove(&todo.id); let mut times = times.remove(&todo.id).unwrap_or_default(); + let reminders = reminders.remove(&todo.id); if times.len() > 1 { warn!("Todo with multiple times found - todo-uuid: {}", todo.id); @@ -140,6 +165,7 @@ impl TodoService { tags, category, checks, + reminders, } }) .collect(); @@ -172,6 +198,7 @@ impl TodoService { tags: None, category: None, checks: None, + reminders: None, }, todo.owner_id, ) @@ -205,6 +232,20 @@ impl TodoService { .map(to_tag_dto) .collect::, _>>()?; let tags = if tags.is_empty() { None } else { Some(tags) }; + + let reminders = todo + .find_related(Reminder) + .all(db) + .await? + .into_iter() + .map(Self::to_reminder_dto) + .collect::, _>>()?; + let reminders = if reminders.is_empty() { + None + } else { + Some(reminders) + }; + let checks: Vec<_> = todo .find_related(Check) .all(db) @@ -228,6 +269,7 @@ impl TodoService { tags, category: category.map(to_category_dto).transpose()?, checks, + reminders, })) } @@ -293,6 +335,18 @@ impl TodoService { .await?; } + if let Some(reminders) = todo.reminders + && !reminders.is_empty() + { + Reminder::insert_many(reminders.into_iter().map(|reminder| { + let mut model = Self::to_reminder_model(reminder); + model.todo_id = Set(todo.uuid); + model + })) + .exec(&tx) + .await?; + } + if let Some(time) = todo.time { let mut time_model = Self::to_time_model(time); time_model.todo_id = Set(todo.uuid); @@ -328,6 +382,7 @@ impl TodoService { .exec(&tx) .await?; + // Sync tags let incoming: HashSet<_> = todo.tags.unwrap_or_default().iter().cloned().collect(); let existing: HashSet<_> = TodoTag::find() .filter(todo_tag::COLUMN.todo_id.eq(todo.uuid)) @@ -357,6 +412,61 @@ impl TodoService { .await?; } + // Sync reminders + let incoming: HashSet<_> = todo.reminders.unwrap_or_default().iter().cloned().collect(); + let existing: HashSet<_> = Reminder::find() + .filter(reminder::COLUMN.todo_id.eq(todo.uuid)) + .all(&tx) + .await? + .into_iter() + .filter_map(|model_reminder| { + let id = model_reminder.id; + Self::to_reminder_dto(model_reminder) + .map(|reminder| (reminder, id)) + .ok() + }) + .collect(); + + let to_insert: Vec<_> = incoming + .iter() + .filter(|incoming_item| { + !existing + .iter() + .any(|existing_item| **incoming_item == existing_item.0) + }) + .cloned() + .collect(); + let to_delete: Vec<_> = existing + .iter() + .filter(|existing_item| { + !incoming + .iter() + .any(|incoming_item| existing_item.0 == *incoming_item) + }) + .cloned() + .collect(); + + if !to_delete.is_empty() { + Reminder::delete_many() + .filter( + reminder::COLUMN + .id + .is_in(to_delete.into_iter().map(|id| id.1)), + ) + .exec(&tx) + .await?; + } + + if !to_insert.is_empty() { + Reminder::insert_many(to_insert.into_iter().map(|reminder| { + let mut model = Self::to_reminder_model(reminder); + model.todo_id = Set(todo.uuid); + model + })) + .exec(&tx) + .await?; + } + let has_time = Time::find_by_id(todo.uuid).exists(&tx).await?; if let Some(time) = todo.time { let mut time_model: time::ActiveModel = Self::to_time_model(time); @@ -656,4 +766,49 @@ impl TodoService { Ok(time) } + + fn to_reminder_model(value: ReminderModel) -> reminder::ActiveModel { + match value { + ReminderModel::Time { inner } => reminder::ActiveModel { + format: Set("time".to_string()), + time: Set(Some(inner.time.naive_utc())), + ..Default::default() + }, + ReminderModel::Relative { inner } => reminder::ActiveModel { + format: Set("relative".to_string()), + offset: Set(Some(inner.offset)), + ..Default::default() + }, + } + } + + fn to_reminder_dto(value: reminder::Model) -> Result { + let reminder = match value.format.as_str() { + "time" => types::Reminder::Time { + inner: ReminderTime { + time: value + .time + .ok_or(TodoServiceError::InvalidReminder( + "Required time not found".to_string(), + ))? + .and_utc(), + }, + }, + "relative" => types::Reminder::Relative { + inner: ReminderRelative { + offset: value.offset.ok_or(TodoServiceError::InvalidReminder( + "Required offset not found".to_string(), + ))?, + }, + }, + format => { + return Err(TodoServiceError::InvalidReminder(format!( + "unknown reminder format: {}", + format + ))); + } + }; + + Ok(reminder) + } } diff --git a/api/tests/todos/add.rs b/api/tests/todos/add.rs index 2a56df1..e027fef 100644 --- a/api/tests/todos/add.rs +++ b/api/tests/todos/add.rs @@ -1,7 +1,10 @@ use crate::common::client::get_client; use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; use serde_json::json; -use types::{Category, HexColor, Tag, TimePoint, TimeRange, TimeRecurring, Todo}; +use types::{ + Category, HexColor, Reminder, ReminderRelative, ReminderTime, Tag, TimePoint, TimeRange, + TimeRecurring, Todo, +}; use uuid::Uuid; #[tokio::test] @@ -17,6 +20,7 @@ async fn add_todo() { tags: None, category: None, checks: None, + reminders: None, }; let response = client @@ -70,6 +74,65 @@ async fn add_todo_known_uuid() { response.assert_status_bad_request(); } +#[tokio::test] +async fn add_todo_with_reminder() { + let client = get_client().await; + + let todo_uuid = Uuid::new_v4(); + let todo = Todo { + uuid: todo_uuid, + title: "Test Todo".to_string(), + note: "Test Note".to_string(), + time: None, + tags: None, + category: None, + checks: None, + reminders: Some(vec![ + Reminder::Time { + inner: ReminderTime { + time: DateTime::from_naive_utc_and_offset( + NaiveDate::from_ymd_opt(1970, 1, 1) + .unwrap() + .and_time(NaiveTime::from_hms_opt(20, 5, 2).unwrap()), + Utc, + ), + }, + }, + Reminder::Relative { + inner: ReminderRelative { offset: 10 }, + }, + ]), + }; + + let response = client + .add_todo(json!( + { + "data": { + "uuid": todo_uuid, + "title": "Test Todo", + "note": "Test Note", + "reminders": [ + { + "type": "time", + "time": "1970-01-01T20:05:02.0Z" + }, + { + "type": "relative", + "offset": 10 + } + ], + } + })) + .await; + + response.assert_status_success(); + response.assert_json(&todo); + + let todos = client.get_todos_json().await; + assert_eq!(todos.len(), 1); + assert_eq!(todos.first().unwrap(), &todo); +} + #[tokio::test] async fn add_todo_with_time_point() { let client = get_client().await; @@ -92,6 +155,7 @@ async fn add_todo_with_time_point() { tags: None, category: None, checks: None, + reminders: None, }; let response = client @@ -135,6 +199,7 @@ async fn add_todo_with_time_range() { tags: None, category: None, checks: None, + reminders: None, }; let response = client @@ -178,6 +243,7 @@ async fn add_todo_with_time_recurring_weekly() { tags: None, category: None, checks: None, + reminders: None, }; let response = client @@ -222,6 +288,7 @@ async fn add_todo_with_time_recurring_daily() { tags: None, category: None, checks: None, + reminders: None, }; let response = client @@ -263,6 +330,7 @@ async fn add_todo_with_time_recurring_yearly() { tags: None, category: None, checks: None, + reminders: None, }; let response = client @@ -379,6 +447,7 @@ async fn add_todo_with_labels() { icon: "icon".to_string(), }), checks: None, + reminders: None, }; let json = client diff --git a/api/tests/todos/delete.rs b/api/tests/todos/delete.rs index a1aab7a..069324c 100644 --- a/api/tests/todos/delete.rs +++ b/api/tests/todos/delete.rs @@ -152,6 +152,7 @@ async fn get_deleted_todo_with_labels() { icon: "icon".to_string(), }), checks: None, + reminders: None, }; client @@ -222,6 +223,7 @@ async fn reactivate_deleted_todo_recover_labels() { icon: "icon".to_string(), }), checks: None, + reminders: None, }; client diff --git a/api/tests/todos/update.rs b/api/tests/todos/update.rs index 81f4d37..db89673 100644 --- a/api/tests/todos/update.rs +++ b/api/tests/todos/update.rs @@ -59,6 +59,75 @@ async fn update_todo_unknown() { response.assert_status_not_found(); } +#[tokio::test] +async fn update_todo_with_reminders() { + 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", + } + } + )) + .await; + + client + .update_todo_json(json!( + { + "uuid": todo_uuid, + "title": "updated Todo", + "note": "Test Note", + "reminders": [ + { + "type": "time", + "time": "1970-01-01T20:05:02.0Z" + }, + { + "type": "relative", + "offset": 10 + } + ], + } + )) + .await; + + let todos = client.get_todos_json().await; + + assert_eq!(todos.len(), 1); + assert_eq!(todos[0].uuid, todo_uuid); + assert_eq!(todos[0].title, "updated Todo"); + assert_eq!(todos[0].reminders.as_ref().unwrap().len(), 2); + + client + .update_todo_json(json!( + { + "uuid": todo_uuid, + "title": "updated Todo", + "note": "Test Note", + "reminders": [ + { + "type": "relative", + "offset": 20 + } + ], + } + )) + .await; + + let todos = client.get_todos_json().await; + + assert_eq!(todos.len(), 1); + assert_eq!(todos[0].uuid, todo_uuid); + assert_eq!(todos[0].title, "updated Todo"); + assert_eq!(todos[0].reminders.as_ref().unwrap().len(), 1); +} + #[tokio::test] async fn update_todo_with_labels() { let client = get_client().await; @@ -167,6 +236,7 @@ async fn update_todo_with_time_point() { tags: None, category: None, checks: None, + reminders: None, }; client @@ -224,6 +294,7 @@ async fn update_todo_with_time_range() { tags: None, category: None, checks: None, + reminders: None, }; client @@ -278,6 +349,7 @@ async fn update_todo_with_time_recurring_weekly() { tags: None, category: None, checks: None, + reminders: None, }; client @@ -333,6 +405,7 @@ async fn update_todo_with_time_recurring_daily() { tags: None, category: None, checks: None, + reminders: None, }; client @@ -386,6 +459,7 @@ async fn update_todo_with_time_recurring_yearly() { tags: None, category: None, checks: None, + reminders: None, }; client @@ -439,6 +513,7 @@ async fn update_todo_with_remove_time() { tags: None, category: None, checks: None, + reminders: None, }; client diff --git a/app/utils/types.ts b/app/utils/types.ts index 5ce9e55..55b37d9 100644 --- a/app/utils/types.ts +++ b/app/utils/types.ts @@ -29,12 +29,25 @@ export type Time = | (TimePoint & { type: "point" }) | (TimeRecurring & { type: "recurring" }); +export type ReminderTime = { + time: string; // ISO datetime +}; + +export type ReminderRelative = { + offset: number; +}; + +export type Reminder = + | (ReminderTime & { type: "time" }) + | (ReminderRelative & { type: "relative" }); + export type CreateTodoData = { title: string; note: string; time?: Time; tags?: UUID[]; category?: UUID; + reminders?: Reminder[]; }; export type Todo = { @@ -45,6 +58,7 @@ export type Todo = { tags?: Label[]; category?: Category; checks?: string[]; // ISO dates + reminders?: Reminder[]; }; export type Label = { diff --git a/cli/src/main.rs b/cli/src/main.rs index 31aac0e..1c3c025 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -107,9 +107,10 @@ async fn main() -> eyre::Result<()> { uuid: uuid::Uuid::new_v4(), title, note: note.unwrap_or_default(), - time: None, tags: Some(tags), category: categories, + time: None, + reminders: None, }, position: None, previous_id: None, diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index 140c80d..2f0dc7a 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -14,6 +14,7 @@ pub struct CreateTodoData { pub time: Option